diff --git a/lib/components/account/add_expense_dialog.dart b/lib/components/account/add_expense_dialog.dart index 0d03254..68df00c 100644 --- a/lib/components/account/add_expense_dialog.dart +++ b/lib/components/account/add_expense_dialog.dart @@ -1,3 +1,60 @@ +/// AddExpenseDialog +/// ================= +/// +/// A comprehensive dialog widget for creating or editing an expense within a group. +/// This dialog supports multi-currency, receipt image upload, flexible splitting (equal or custom), +/// and integrates with the ExpenseBloc for state management. +/// +/// ## Features +/// - Form for entering expense details: description, amount, currency, category, date, payer, splits +/// - Receipt image upload with file size validation (max 5MB) +/// - Split expense equally or custom among group members +/// - Multi-currency support with automatic conversion to EUR +/// - Category selection with icons +/// - Date picker for expense date +/// - Paid by selection from group members +/// - Real-time split calculation and validation +/// - Form validation and error feedback +/// - Loading state during submission +/// - Integrates with ExpenseBloc for create/update actions +/// - Handles both creation and editing of expenses +/// +/// ## Usage Example +/// ```dart +/// showDialog( +/// context: context, +/// builder: (context) => AddExpenseDialog( +/// group: group, +/// currentUser: user, +/// expenseToEdit: existingExpense, // null for new expense +/// ), +/// ); +/// ``` +/// +/// ## State Management +/// - Uses ExpenseBloc for dispatching CreateExpense and UpdateExpense events +/// - Reads exchange rates from ExpensesLoaded state for currency conversion +/// +/// ## Validation +/// - Description: required +/// - Amount: required, positive number +/// - Splits: at least one participant, total must match amount +/// - Receipt: optional, max 5MB +/// +/// ## Accessibility +/// - All form fields have labels and icons +/// - Keyboard and screen reader friendly +/// +/// ## Dependencies +/// - flutter_bloc +/// - image_picker +/// - intl +/// - Custom models: Expense, ExpenseSplit, Group +/// +/// ## See also +/// - Expense +/// - Group +/// - ExpenseBloc import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,11 +68,23 @@ import '../../blocs/user/user_state.dart' as user_state; import '../../models/group.dart'; import '../../models/expense.dart'; +/// A dialog for creating or editing an expense in a group. +/// +/// Accepts the group, current user, and optionally an expense to edit. +/// Shows a form for all expense details, supports receipt upload, and flexible splitting. class AddExpenseDialog extends StatefulWidget { + /// The group to which the expense belongs. final Group group; + /// The user creating or editing the expense. final user_state.UserModel currentUser; + /// The expense to edit (null for new expense). final Expense? expenseToEdit; + /// Creates an AddExpenseDialog. + /// + /// [group] is the group for the expense. + /// [currentUser] is the user creating/editing. + /// [expenseToEdit] is the expense to edit, or null for new. const AddExpenseDialog({ super.key, required this.group, @@ -27,38 +96,52 @@ class AddExpenseDialog extends StatefulWidget { State createState() => _AddExpenseDialogState(); } +/// State for AddExpenseDialog. +/// +/// Handles form logic, validation, image picking, split calculation, and submission. class _AddExpenseDialogState extends State { + /// Form key for validating the expense form. final _formKey = GlobalKey(); + /// Controller for the expense description field. final _descriptionController = TextEditingController(); + /// Controller for the expense amount field. final _amountController = TextEditingController(); - + /// The selected date for the expense. late DateTime _selectedDate; + /// The selected category for the expense. late ExpenseCategory _selectedCategory; + /// The selected currency for the expense. late ExpenseCurrency _selectedCurrency; + /// The user ID of the payer. late String _paidById; + /// Map of userId to split amount for each participant. final Map _splits = {}; + /// The selected receipt image file, if any. File? _receiptImage; + /// Whether the dialog is currently submitting data. bool _isLoading = false; + /// Whether the expense is split equally among participants. bool _splitEqually = true; @override void initState() { super.initState(); + // Initialize form fields and splits based on whether editing or creating _selectedDate = widget.expenseToEdit?.date ?? DateTime.now(); _selectedCategory = widget.expenseToEdit?.category ?? ExpenseCategory.other; _selectedCurrency = widget.expenseToEdit?.currency ?? ExpenseCurrency.eur; _paidById = widget.expenseToEdit?.paidById ?? widget.currentUser.id; if (widget.expenseToEdit != null) { + // Editing: pre-fill fields and splits _descriptionController.text = widget.expenseToEdit!.description; _amountController.text = widget.expenseToEdit!.amount.toString(); - for (final split in widget.expenseToEdit!.splits) { _splits[split.userId] = split.amount; } _splitEqually = false; } else { - // Initialiser avec tous les membres sélectionnés + // Creating: initialize splits for all group members for (final member in widget.group.members) { _splits[member.userId] = 0; } @@ -67,11 +150,16 @@ class _AddExpenseDialogState extends State { @override void dispose() { + // Dispose controllers to free resources _descriptionController.dispose(); _amountController.dispose(); super.dispose(); } + /// Opens the image picker and validates the selected image (max 5MB). + /// + /// If valid, sets [_receiptImage] to the selected file. + /// Shows an error message if the file is too large. Future _pickImage() async { final picker = ImagePicker(); final pickedFile = await picker.pickImage( @@ -102,6 +190,9 @@ class _AddExpenseDialogState extends State { } } + /// Calculates splits for equal division among selected members. + /// + /// Updates [_splits] map with equal amounts for each selected participant. void _calculateSplits() { if (!_splitEqually) return; @@ -119,6 +210,12 @@ class _AddExpenseDialogState extends State { }); } + /// Validates the form and submits the expense. + /// + /// - Checks all form fields and splits + /// - Converts amount to EUR using exchange rates + /// - Dispatches CreateExpense or UpdateExpense event to ExpenseBloc + /// - Shows success or error feedback Future _submit() async { if (!_formKey.currentState!.validate()) return; @@ -211,6 +308,9 @@ class _AddExpenseDialogState extends State { } } + /// Builds the dialog UI with all form fields and controls. + /// + /// Returns a Dialog widget containing the expense form. @override Widget build(BuildContext context) { return Dialog( diff --git a/lib/components/account/balances_tab.dart b/lib/components/account/balances_tab.dart index 4fa5872..aa4b3ac 100644 --- a/lib/components/account/balances_tab.dart +++ b/lib/components/account/balances_tab.dart @@ -1,9 +1,25 @@ +/// This file defines the `BalancesTab` widget, which is responsible for displaying a list of user balances +/// in a group. Each balance is represented as a card, showing the user's name, the amount they have paid, +/// the amount they owe, and their overall balance status (to pay, to receive, or balanced). +/// +/// The widget handles both light and dark themes and provides a fallback UI for empty balance lists. + import 'package:flutter/material.dart'; import '../../models/user_balance.dart'; +/// A stateless widget that displays a list of user balances in a group. +/// +/// The `BalancesTab` widget takes a list of `UserBalance` objects and renders them as cards in a scrollable list. +/// Each card provides detailed information about the user's financial status within the group. +/// +/// If the list of balances is empty, a placeholder message is displayed. class BalancesTab extends StatelessWidget { + /// The list of user balances to display. final List balances; + /// Creates a `BalancesTab` widget. + /// + /// The [balances] parameter must not be null. const BalancesTab({ super.key, required this.balances, @@ -11,12 +27,14 @@ class BalancesTab extends StatelessWidget { @override Widget build(BuildContext context) { + // Check if the balances list is empty and display a placeholder message if true. if (balances.isEmpty) { return const Center( child: Text('Aucune balance à afficher'), ); } + // Render the list of balances as a scrollable list. return ListView.builder( padding: const EdgeInsets.all(16), itemCount: balances.length, @@ -27,13 +45,20 @@ class BalancesTab extends StatelessWidget { ); } + /// Builds a card widget to display a single user's balance information. + /// + /// The card includes the user's name, their total paid and owed amounts, and their balance status. + /// The card's appearance adapts to the app's current theme (light or dark). Widget _buildBalanceCard(BuildContext context, UserBalance balance) { + // Determine if the app is in dark mode. final isDark = Theme.of(context).brightness == Brightness.dark; - + + // Define variables for the balance's color, icon, and status text. Color balanceColor; IconData balanceIcon; String balanceText; + // Determine the balance status and corresponding UI elements. if (balance.shouldReceive) { balanceColor = Colors.green; balanceIcon = Icons.arrow_downward; @@ -48,12 +73,14 @@ class BalancesTab extends StatelessWidget { balanceText = 'Équilibré'; } + // Build and return the card widget. return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ + // Display the user's initial in a circular avatar. CircleAvatar( radius: 24, backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], @@ -68,10 +95,12 @@ class BalancesTab extends StatelessWidget { ), ), const SizedBox(width: 16), + // Display the user's name and financial details. Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // User's name. Text( balance.userName, style: const TextStyle( @@ -80,6 +109,7 @@ class BalancesTab extends StatelessWidget { ), ), const SizedBox(height: 4), + // User's total paid and owed amounts. Text( 'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)} €', style: TextStyle( @@ -90,13 +120,16 @@ class BalancesTab extends StatelessWidget { ], ), ), + // Display the user's balance status and amount. Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Row( children: [ + // Icon indicating the balance status. Icon(balanceIcon, size: 16, color: balanceColor), const SizedBox(width: 4), + // User's absolute balance amount. Text( '${balance.absoluteBalance.toStringAsFixed(2)} €', style: TextStyle( @@ -107,6 +140,7 @@ class BalancesTab extends StatelessWidget { ), ], ), + // Text indicating the balance status (e.g., "À recevoir"). Text( balanceText, style: TextStyle( diff --git a/lib/components/account/expense_detail_dialog.dart b/lib/components/account/expense_detail_dialog.dart index e9390a4..7a800e5 100644 --- a/lib/components/account/expense_detail_dialog.dart +++ b/lib/components/account/expense_detail_dialog.dart @@ -1,3 +1,11 @@ +/// This file defines the `ExpenseDetailDialog` widget, which provides a detailed view of a specific expense +/// within a group. It allows users to view expense details, such as the amount, payer, date, and splits, and +/// perform actions like editing, deleting, or archiving the expense. +/// +/// The dialog is highly interactive and adapts its UI based on the current user's permissions and the state +/// of the expense. It also integrates with BLoC for state management and supports features like receipt display +/// and split payment marking. + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; @@ -10,10 +18,20 @@ import '../../models/expense.dart'; import '../../models/group.dart'; import 'add_expense_dialog.dart'; +/// A stateless widget that displays detailed information about a specific expense. +/// +/// The `ExpenseDetailDialog` widget shows the expense's amount, payer, date, splits, and receipt. It also +/// provides actions for editing, deleting, or archiving the expense, depending on the current user's permissions. class ExpenseDetailDialog extends StatelessWidget { + /// The expense to display details for. final Expense expense; + + /// The group to which the expense belongs. final Group group; + /// Creates an `ExpenseDetailDialog` widget. + /// + /// The [expense] and [group] parameters must not be null. const ExpenseDetailDialog({ super.key, required this.expense, @@ -22,11 +40,13 @@ class ExpenseDetailDialog extends StatelessWidget { @override Widget build(BuildContext context) { + // Formatters for displaying dates and times. final dateFormat = DateFormat('dd MMMM yyyy'); final timeFormat = DateFormat('HH:mm'); return BlocBuilder( builder: (context, userState) { + // Determine the current user and their permissions. final currentUser = userState is user_state.UserLoaded ? userState.user : null; final canEdit = currentUser?.id == expense.paidById; @@ -39,6 +59,7 @@ class ExpenseDetailDialog extends StatelessWidget { automaticallyImplyLeading: false, actions: [ if (canEdit) ...[ + // Edit button. IconButton( icon: const Icon(Icons.edit), onPressed: () { @@ -46,11 +67,13 @@ class ExpenseDetailDialog extends StatelessWidget { _showEditDialog(context, currentUser!); }, ), + // Delete button. IconButton( icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _confirmDelete(context), ), ], + // Close button. IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), @@ -60,7 +83,7 @@ class ExpenseDetailDialog extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ - // En-tête avec icône + // Header with icon and description. Center( child: Column( children: [ @@ -99,7 +122,7 @@ class ExpenseDetailDialog extends StatelessWidget { ), const SizedBox(height: 24), - // Montant + // Amount card. Card( child: Padding( padding: const EdgeInsets.all(16), @@ -127,11 +150,11 @@ class ExpenseDetailDialog extends StatelessWidget { ), const SizedBox(height: 16), - // Informations + // Information rows. _buildInfoRow(Icons.person, 'Payé par', expense.paidByName), _buildInfoRow(Icons.calendar_today, 'Date', dateFormat.format(expense.date)), _buildInfoRow(Icons.access_time, 'Heure', timeFormat.format(expense.createdAt)), - + if (expense.isEdited && expense.editedAt != null) _buildInfoRow( Icons.edit, @@ -143,7 +166,7 @@ class ExpenseDetailDialog extends StatelessWidget { const Divider(), const SizedBox(height: 8), - // Divisions + // Splits section. const Text( 'Répartition', style: TextStyle( @@ -156,7 +179,7 @@ class ExpenseDetailDialog extends StatelessWidget { const SizedBox(height: 16), - // Reçu + // Receipt section. if (expense.receiptUrl != null) ...[ const Divider(), const SizedBox(height: 8), @@ -188,7 +211,7 @@ class ExpenseDetailDialog extends StatelessWidget { const SizedBox(height: 16), - // Bouton archiver + // Archive button. if (!expense.isArchived && canEdit) OutlinedButton.icon( onPressed: () => _confirmArchive(context), @@ -204,6 +227,7 @@ class ExpenseDetailDialog extends StatelessWidget { ); } + /// Builds a row displaying an icon, a label, and a value. Widget _buildInfoRow(IconData icon, String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), @@ -231,6 +255,10 @@ class ExpenseDetailDialog extends StatelessWidget { ); } + /// Builds a tile displaying details about a split in the expense. + /// + /// The tile shows the user's name, the split amount, and whether the split is paid. If the current user + /// is responsible for the split and it is unpaid, a button is provided to mark it as paid. Widget _buildSplitTile(BuildContext context, ExpenseSplit split) { return BlocBuilder( builder: (context, userState) { @@ -283,6 +311,7 @@ class ExpenseDetailDialog extends StatelessWidget { ); } + /// Shows a dialog for editing the expense. void _showEditDialog(BuildContext context, user_state.UserModel currentUser) { showDialog( context: context, @@ -297,6 +326,7 @@ class ExpenseDetailDialog extends StatelessWidget { ); } + /// Shows a confirmation dialog for deleting the expense. void _confirmDelete(BuildContext context) { showDialog( context: context, @@ -324,6 +354,7 @@ class ExpenseDetailDialog extends StatelessWidget { ); } + /// Shows a confirmation dialog for archiving the expense. void _confirmArchive(BuildContext context) { showDialog( context: context,