feat: Enhance documentation for AddExpenseDialog, BalancesTab, and ExpenseDetailDialog with detailed comments and usage examples

This commit is contained in:
Dayron
2025-10-31 17:04:28 +01:00
parent 2faf37f145
commit 48be18460c
3 changed files with 176 additions and 11 deletions

View File

@@ -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<AddExpenseDialog> createState() => _AddExpenseDialogState();
}
/// State for AddExpenseDialog.
///
/// Handles form logic, validation, image picking, split calculation, and submission.
class _AddExpenseDialogState extends State<AddExpenseDialog> {
/// Form key for validating the expense form.
final _formKey = GlobalKey<FormState>();
/// 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<String, double> _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<AddExpenseDialog> {
@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<void> _pickImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(
@@ -102,6 +190,9 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
}
}
/// 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<AddExpenseDialog> {
});
}
/// 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<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
@@ -211,6 +308,9 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
}
}
/// 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(

View File

@@ -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<UserBalance> 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(

View File

@@ -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<UserBloc, user_state.UserState>(
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<UserBloc, user_state.UserState>(
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,