/// 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 library; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:travel_mate/models/expense_split.dart'; import '../../services/error_service.dart'; import '../../blocs/expense/expense_bloc.dart'; import '../../blocs/expense/expense_event.dart'; import '../../blocs/expense/expense_state.dart'; 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, required this.currentUser, this.expenseToEdit, }); @override 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; /// Whether the existing receipt has been removed. bool _receiptRemoved = false; @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 { // Creating: initialize splits for all group members for (final member in widget.group.members) { _splits[member.userId] = 0; } } } @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( source: ImageSource.gallery, maxWidth: 1920, maxHeight: 1920, ); if (pickedFile != null) { final file = File(pickedFile.path); final fileSize = await file.length(); if (fileSize > 5 * 1024 * 1024) { if (mounted) { ErrorService().showError( message: 'L\'image ne doit pas dépasser 5 Mo', ); } return; } setState(() { _receiptImage = file; }); } } /// Calculates splits for equal division among selected members. /// /// Updates [_splits] map with equal amounts for each selected participant. void _calculateSplits() { if (!_splitEqually) return; final amount = double.tryParse(_amountController.text) ?? 0; final selectedMembers = _splits.entries.where((e) => e.value >= 0).toList(); if (selectedMembers.isEmpty) return; final splitAmount = amount / selectedMembers.length; setState(() { for (final entry in selectedMembers) { _splits[entry.key] = splitAmount; } }); } /// 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; final amount = double.parse(_amountController.text); final selectedSplits = _splits.entries.where((e) => e.value > 0).map((e) { final member = widget.group.members.firstWhere((m) => m.userId == e.key); return ExpenseSplit( userId: e.key, userName: member.firstName, amount: e.value, ); }).toList(); if (selectedSplits.isEmpty) { ErrorService().showError( message: 'Veuillez sélectionner au moins un participant', ); return; } setState(() => _isLoading = true); try { // Convertir en EUR final amountInEur = context.read().state is ExpensesLoaded ? ((context.read().state as ExpensesLoaded) .exchangeRates[_selectedCurrency.code] ?? 1.0) * amount : amount; final payer = widget.group.members.firstWhere( (m) => m.userId == _paidById, ); final expense = Expense( id: widget.expenseToEdit?.id ?? '', groupId: widget.group.id, description: _descriptionController.text.trim(), amount: amount, currency: _selectedCurrency, amountInEur: amountInEur, category: _selectedCategory, paidById: _paidById, paidByName: payer.firstName, splits: selectedSplits, date: _selectedDate, receiptUrl: _receiptRemoved ? null : widget.expenseToEdit?.receiptUrl, createdAt: widget.expenseToEdit?.createdAt ?? DateTime.now(), ); if (widget.expenseToEdit == null) { context.read().add( CreateExpense(expense: expense, receiptImage: _receiptImage), ); } else { context.read().add( UpdateExpense(expense: expense, newReceiptImage: _receiptImage), ); } if (mounted) { Navigator.of(context).pop(); ErrorService().showSnackbar( message: widget.expenseToEdit == null ? 'Dépense ajoutée' : 'Dépense modifiée', isError: false, ); } } catch (e) { if (mounted) { ErrorService().showError(message: 'Erreur: $e'); } } finally { if (mounted) { setState(() => _isLoading = false); } } } /// Builds the dialog UI with all form fields and controls. /// /// Returns a Dialog widget containing the expense form. @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), backgroundColor: isDark ? theme.scaffoldBackgroundColor : Colors.white, insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Container( constraints: const BoxConstraints(maxWidth: 500, maxHeight: 800), child: Column( children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(24, 24, 16, 16), child: Row( children: [ Expanded( child: Text( widget.expenseToEdit == null ? 'Nouvelle dépense' : 'Modifier la dépense', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), const Divider(height: 1), // Form Content Expanded( child: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(24), children: [ // Description TextFormField( controller: _descriptionController, decoration: InputDecoration( labelText: 'Description', hintText: 'Ex: Restaurant, Essence...', prefixIcon: const Icon(Icons.description_outlined), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Requis'; } return null; }, ), const SizedBox(height: 16), // Montant et Devise Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 2, child: TextFormField( controller: _amountController, decoration: InputDecoration( labelText: 'Montant', prefixIcon: const Icon(Icons.euro_symbol), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), onChanged: (_) => _calculateSplits(), validator: (value) { if (value == null || value.isEmpty) { return 'Requis'; } if (double.tryParse(value) == null || double.parse(value) <= 0) { return 'Invalide'; } return null; }, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( initialValue: _selectedCurrency, decoration: InputDecoration( labelText: 'Devise', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 16, ), ), items: ExpenseCurrency.values.map((currency) { return DropdownMenuItem( value: currency, child: Text(currency.code), ); }).toList(), onChanged: (value) { if (value != null) { setState(() => _selectedCurrency = value); } }, ), ), ], ), const SizedBox(height: 16), // Catégorie DropdownButtonFormField( initialValue: _selectedCategory, decoration: InputDecoration( labelText: 'Catégorie', prefixIcon: Icon(_selectedCategory.icon), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), ), items: ExpenseCategory.values.map((category) { return DropdownMenuItem( value: category, child: Text(category.displayName), ); }).toList(), onChanged: (value) { if (value != null) { setState(() => _selectedCategory = value); } }, ), const SizedBox(height: 16), // Date InkWell( onTap: () async { final date = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime(2020), lastDate: DateTime.now().add( const Duration(days: 365), ), ); if (date != null) setState(() => _selectedDate = date); }, borderRadius: BorderRadius.circular(8), child: InputDecorator( decoration: InputDecoration( labelText: 'Date', prefixIcon: const Icon(Icons.calendar_today_outlined), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), ), child: Text( DateFormat('dd/MM/yyyy').format(_selectedDate), style: theme.textTheme.bodyLarge, ), ), ), const SizedBox(height: 16), // Payé par DropdownButtonFormField( initialValue: _paidById, decoration: InputDecoration( labelText: 'Payé par', prefixIcon: const Icon(Icons.person_outline), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), ), items: widget.group.members.map((member) { return DropdownMenuItem( value: member.userId, child: Text(member.firstName), ); }).toList(), onChanged: (value) { if (value != null) setState(() => _paidById = value); }, ), const SizedBox(height: 24), // Division Section Container( decoration: BoxDecoration( color: isDark ? Colors.grey[800] : Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.dividerColor), ), padding: const EdgeInsets.all(16), child: Column( children: [ Row( children: [ const Text( 'Division', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), const Spacer(), Text( 'Égale', style: TextStyle( color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), const SizedBox(width: 8), Switch( value: _splitEqually, onChanged: (value) { setState(() { _splitEqually = value; if (value) _calculateSplits(); }); }, activeThumbColor: theme.colorScheme.primary, ), ], ), const Divider(), ...widget.group.members.map((member) { final isSelected = _splits.containsKey(member.userId) && _splits[member.userId]! >= 0; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Expanded( child: Text( member.firstName, style: const TextStyle(fontSize: 16), ), ), if (!_splitEqually && isSelected) SizedBox( width: 100, child: TextFormField( initialValue: _splits[member.userId] ?.toStringAsFixed(2), decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 8, vertical: 8, ), border: OutlineInputBorder(), suffixText: '€', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), onChanged: (value) { final amount = double.tryParse(value) ?? 0; setState( () => _splits[member.userId] = amount, ); }, ), ), Checkbox( value: isSelected, onChanged: (value) { setState(() { if (value == true) { _splits[member.userId] = 0; if (_splitEqually) _calculateSplits(); } else { _splits[member.userId] = -1; } }); }, activeColor: theme.colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), ), ], ), ); }), ], ), ), const SizedBox(height: 16), // Reçu (Optional - keeping simple for now as per design focus) if (_receiptImage != null || (widget.expenseToEdit?.receiptUrl != null && !_receiptRemoved)) Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border.all(color: theme.dividerColor), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ const Icon(Icons.receipt_long, color: Colors.green), const SizedBox(width: 8), const Text('Reçu joint'), const Spacer(), IconButton( icon: const Icon(Icons.close), onPressed: () => setState(() { _receiptImage = null; _receiptRemoved = true; }), ), ], ), ) else OutlinedButton.icon( onPressed: _pickImage, icon: const Icon(Icons.camera_alt_outlined), label: const Text('Ajouter un reçu'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ], ), ), ), // Bottom Button Padding( padding: const EdgeInsets.all(24), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isLoading ? null : _submit, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), backgroundColor: theme.colorScheme.primary, foregroundColor: Colors.white, elevation: 0, ), child: _isLoading ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Colors.white, ), ), ) : Text( widget.expenseToEdit == null ? 'Ajouter' : 'Enregistrer', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ), ), ), ], ), ), ); } }