feat: Enhance documentation for AddExpenseDialog, BalancesTab, and ExpenseDetailDialog with detailed comments and usage examples
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,7 +150,7 @@ 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)),
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user