From ce754c1e6c7d2563710dcc2c394ca3b688dfa1bb Mon Sep 17 00:00:00 2001 From: Dayron Date: Mon, 20 Oct 2025 19:22:57 +0200 Subject: [PATCH] feat: Add expense management features with tabs for expenses, balances, and settlements - Implemented ExpensesTab to display a list of expenses with details. - Created GroupExpensesPage to manage group expenses with a tabbed interface. - Added SettlementsTab to show optimized settlements between users. - Developed data models for Expense and Balance, including necessary methods for serialization. - Introduced CountRepository for Firestore interactions related to expenses. - Added CountService to handle business logic for expenses and settlements. - Integrated image picker for receipt uploads. - Updated main.dart to include CountBloc and CountRepository. - Enhanced pubspec.yaml with new dependencies for image picking and Firebase storage. Not Tested yet --- lib/blocs/count/count_bloc.dart | 197 +++++++ lib/blocs/count/count_event.dart | 91 ++++ lib/blocs/count/count_state.dart | 42 ++ lib/blocs/group/group_state.dart | 6 + lib/components/count/add_expense_dialog.dart | 496 ++++++++++++++++++ lib/components/count/balances_tab.dart | 124 +++++ lib/components/count/count_content.dart | 126 ++++- .../count/expense_detail_dialog.dart | 354 +++++++++++++ lib/components/count/expenses_tab.dart | 142 +++++ lib/components/count/group_expenses_page.dart | 116 ++++ lib/components/count/settlements_tab.dart | 164 ++++++ lib/data/models/balance.dart | 34 ++ lib/data/models/expense.dart | 243 +++++++++ lib/main.dart | 8 + lib/repositories/count_repository.dart | 165 ++++++ lib/services/count_service.dart | 226 ++++++++ pubspec.lock | 136 +++++ pubspec.yaml | 3 + 18 files changed, 2668 insertions(+), 5 deletions(-) create mode 100644 lib/blocs/count/count_bloc.dart create mode 100644 lib/blocs/count/count_event.dart create mode 100644 lib/blocs/count/count_state.dart create mode 100644 lib/components/count/add_expense_dialog.dart create mode 100644 lib/components/count/balances_tab.dart create mode 100644 lib/components/count/expense_detail_dialog.dart create mode 100644 lib/components/count/expenses_tab.dart create mode 100644 lib/components/count/group_expenses_page.dart create mode 100644 lib/components/count/settlements_tab.dart create mode 100644 lib/data/models/balance.dart create mode 100644 lib/data/models/expense.dart create mode 100644 lib/repositories/count_repository.dart create mode 100644 lib/services/count_service.dart diff --git a/lib/blocs/count/count_bloc.dart b/lib/blocs/count/count_bloc.dart new file mode 100644 index 0000000..393224b --- /dev/null +++ b/lib/blocs/count/count_bloc.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/models/expense.dart'; +import '../../data/models/balance.dart'; +import '../../services/count_service.dart'; +import '../../repositories/count_repository.dart'; +import 'count_event.dart'; +import 'count_state.dart'; + +class CountBloc extends Bloc { + final CountService _countService; + StreamSubscription>? _expensesSubscription; + Map _exchangeRates = {}; + + CountBloc({CountService? countService}) + : _countService = countService ?? CountService( + countRepository: CountRepository(), + ), + super(CountInitial()) { + on(_onLoadExpenses); + on(_onCreateExpense); + on(_onUpdateExpense); + on(_onDeleteExpense); + on(_onArchiveExpense); + on(_onMarkSplitAsPaid); + on(_onLoadExchangeRates); + on<_ExpensesUpdated>(_onExpensesUpdated); + } + + Future _onLoadExpenses( + LoadExpenses event, + Emitter emit, + ) async { + emit(CountLoading()); + + // Charger les taux de change + if (_exchangeRates.isEmpty) { + _exchangeRates = await _countService.getExchangeRates(); + } + + await _expensesSubscription?.cancel(); + + _expensesSubscription = _countService + .getExpensesStream(event.groupId, includeArchived: event.includeArchived) + .listen( + (expenses) { + add(_ExpensesUpdated( + groupId: event.groupId, + expenses: expenses, + )); + }, + onError: (error) { + add(_ExpensesError('Erreur lors du chargement des dépenses: $error')); + }, + ); + } + + void _onExpensesUpdated( + _ExpensesUpdated event, + Emitter emit, + ) { + // Récupérer les membres du groupe et calculer les balances + final memberIds = {}; + final memberNames = {}; + + for (final expense in event.expenses) { + memberIds.add(expense.paidById); + memberNames[expense.paidById] = expense.paidByName; + + for (final split in expense.splits) { + memberIds.add(split.userId); + memberNames[split.userId] = split.userName; + } + } + + final balances = _countService.calculateBalances( + event.expenses, + memberIds.toList(), + memberNames, + ); + + final settlements = _countService.calculateOptimizedSettlements(balances); + + emit(ExpensesLoaded( + groupId: event.groupId, + expenses: event.expenses, + balances: balances, + settlements: settlements, + exchangeRates: _exchangeRates, + )); + } + + Future _onCreateExpense( + CreateExpense event, + Emitter emit, + ) async { + try { + await _countService.createExpense( + event.expense, + receiptImage: event.receiptImage, + ); + } catch (e) { + emit(CountError('Erreur lors de la création de la dépense: $e')); + } + } + + Future _onUpdateExpense( + UpdateExpense event, + Emitter emit, + ) async { + try { + await _countService.updateExpense( + event.expense, + newReceiptImage: event.newReceiptImage, + ); + } catch (e) { + emit(CountError('Erreur lors de la modification de la dépense: $e')); + } + } + + Future _onDeleteExpense( + DeleteExpense event, + Emitter emit, + ) async { + try { + await _countService.deleteExpense(event.groupId, event.expenseId); + } catch (e) { + emit(CountError('Erreur lors de la suppression de la dépense: $e')); + } + } + + Future _onArchiveExpense( + ArchiveExpense event, + Emitter emit, + ) async { + try { + await _countService.archiveExpense(event.groupId, event.expenseId); + } catch (e) { + emit(CountError('Erreur lors de l\'archivage de la dépense: $e')); + } + } + + Future _onMarkSplitAsPaid( + MarkSplitAsPaid event, + Emitter emit, + ) async { + try { + await _countService.markSplitAsPaid( + event.groupId, + event.expenseId, + event.userId, + ); + } catch (e) { + emit(CountError('Erreur lors du marquage du paiement: $e')); + } + } + + Future _onLoadExchangeRates( + LoadExchangeRates event, + Emitter emit, + ) async { + try { + _exchangeRates = await _countService.getExchangeRates(); + } catch (e) { + emit(CountError('Erreur lors du chargement des taux de change: $e')); + } + } + + @override + Future close() { + _expensesSubscription?.cancel(); + return super.close(); + } +} + +// Events internes +class _ExpensesUpdated extends CountEvent { + final String groupId; + final List expenses; + + const _ExpensesUpdated({ + required this.groupId, + required this.expenses, + }); + + @override + List get props => [groupId, expenses]; +} + +class _ExpensesError extends CountEvent { + final String error; + + const _ExpensesError(this.error); + + @override + List get props => [error]; +} diff --git a/lib/blocs/count/count_event.dart b/lib/blocs/count/count_event.dart new file mode 100644 index 0000000..ffab27e --- /dev/null +++ b/lib/blocs/count/count_event.dart @@ -0,0 +1,91 @@ +import 'dart:io'; +import 'package:equatable/equatable.dart'; +import '../../data/models/expense.dart'; + +abstract class CountEvent extends Equatable { + const CountEvent(); + + @override + List get props => []; +} + +class LoadExpenses extends CountEvent { + final String groupId; + final bool includeArchived; + + const LoadExpenses(this.groupId, {this.includeArchived = false}); + + @override + List get props => [groupId, includeArchived]; +} + +class CreateExpense extends CountEvent { + final Expense expense; + final File? receiptImage; + + const CreateExpense({ + required this.expense, + this.receiptImage, + }); + + @override + List get props => [expense, receiptImage]; +} + +class UpdateExpense extends CountEvent { + final Expense expense; + final File? newReceiptImage; + + const UpdateExpense({ + required this.expense, + this.newReceiptImage, + }); + + @override + List get props => [expense, newReceiptImage]; +} + +class DeleteExpense extends CountEvent { + final String groupId; + final String expenseId; + + const DeleteExpense({ + required this.groupId, + required this.expenseId, + }); + + @override + List get props => [groupId, expenseId]; +} + +class ArchiveExpense extends CountEvent { + final String groupId; + final String expenseId; + + const ArchiveExpense({ + required this.groupId, + required this.expenseId, + }); + + @override + List get props => [groupId, expenseId]; +} + +class MarkSplitAsPaid extends CountEvent { + final String groupId; + final String expenseId; + final String userId; + + const MarkSplitAsPaid({ + required this.groupId, + required this.expenseId, + required this.userId, + }); + + @override + List get props => [groupId, expenseId, userId]; +} + +class LoadExchangeRates extends CountEvent { + const LoadExchangeRates(); +} diff --git a/lib/blocs/count/count_state.dart b/lib/blocs/count/count_state.dart new file mode 100644 index 0000000..fc49cb9 --- /dev/null +++ b/lib/blocs/count/count_state.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/expense.dart'; +import '../../data/models/balance.dart'; + +abstract class CountState extends Equatable { + const CountState(); + + @override + List get props => []; +} + +class CountInitial extends CountState {} + +class CountLoading extends CountState {} + +class ExpensesLoaded extends CountState { + final String groupId; + final List expenses; + final List balances; + final List settlements; + final Map exchangeRates; + + const ExpensesLoaded({ + required this.groupId, + required this.expenses, + required this.balances, + required this.settlements, + required this.exchangeRates, + }); + + @override + List get props => [groupId, expenses, balances, settlements, exchangeRates]; +} + +class CountError extends CountState { + final String message; + + const CountError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/blocs/group/group_state.dart b/lib/blocs/group/group_state.dart index bbe13d2..ea122aa 100644 --- a/lib/blocs/group/group_state.dart +++ b/lib/blocs/group/group_state.dart @@ -24,6 +24,12 @@ class GroupsLoaded extends GroupState { List get props => [groups]; } +class GroupLoaded extends GroupState { + final List groups; + + const GroupLoaded(this.groups); +} + // Succès d'une opération class GroupOperationSuccess extends GroupState { final String message; diff --git a/lib/components/count/add_expense_dialog.dart b/lib/components/count/add_expense_dialog.dart new file mode 100644 index 0000000..535d627 --- /dev/null +++ b/lib/components/count/add_expense_dialog.dart @@ -0,0 +1,496 @@ +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 '../../blocs/count/count_bloc.dart'; +import '../../blocs/count/count_event.dart'; +import '../../blocs/count/count_state.dart'; +import '../../blocs/user/user_state.dart' as user_state; +import '../../data/models/group.dart'; +import '../../data/models/expense.dart'; + +class AddExpenseDialog extends StatefulWidget { + final Group group; + final user_state.UserModel currentUser; + final Expense? expenseToEdit; + + const AddExpenseDialog({ + super.key, + required this.group, + required this.currentUser, + this.expenseToEdit, + }); + + @override + State createState() => _AddExpenseDialogState(); +} + +class _AddExpenseDialogState extends State { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + final _amountController = TextEditingController(); + + late DateTime _selectedDate; + late ExpenseCategory _selectedCategory; + late ExpenseCurrency _selectedCurrency; + late String _paidById; + final Map _splits = {}; + File? _receiptImage; + bool _isLoading = false; + bool _splitEqually = true; + + @override + void initState() { + super.initState(); + _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) { + _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 + for (final member in widget.group.members) { + _splits[member.userId] = 0; + } + } + } + + @override + void dispose() { + _descriptionController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + 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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('L\'image ne doit pas dépasser 5 Mo'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + setState(() { + _receiptImage = file; + }); + } + } + + 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; + } + }); + } + + 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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Veuillez sélectionner au moins un participant'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // Convertir en EUR + final amountInEur = await context.read().state is ExpensesLoaded + ? (context.read().state as ExpensesLoaded) + .exchangeRates[_selectedCurrency]! * 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: widget.expenseToEdit?.receiptUrl, + ); + + 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(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.expenseToEdit == null + ? 'Dépense ajoutée' + : 'Dépense modifiée'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), + child: Scaffold( + appBar: AppBar( + title: Text(widget.expenseToEdit == null + ? 'Nouvelle dépense' + : 'Modifier la dépense'), + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Description + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + hintText: 'Ex: Restaurant, Essence...', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Veuillez entrer une description'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Montant et devise + Row( + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _amountController, + decoration: const InputDecoration( + labelText: 'Montant', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.euro), + ), + 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 'Montant invalide'; + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: _selectedCurrency, + decoration: const InputDecoration( + labelText: 'Devise', + border: OutlineInputBorder(), + ), + 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: const InputDecoration( + labelText: 'Catégorie', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: ExpenseCategory.values.map((category) { + return DropdownMenuItem( + value: category, + child: Row( + children: [ + Icon(category.icon, size: 20), + const SizedBox(width: 8), + Text(category.displayName), + ], + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedCategory = value); + } + }, + ), + const SizedBox(height: 16), + + // Date + ListTile( + leading: const Icon(Icons.calendar_today), + title: const Text('Date'), + subtitle: Text(DateFormat('dd/MM/yyyy').format(_selectedDate)), + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(4), + ), + 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); + } + }, + ), + const SizedBox(height: 16), + + // Payé par + DropdownButtonFormField( + initialValue: _paidById, + decoration: const InputDecoration( + labelText: 'Payé par', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + 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: 16), + + // Division + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Division', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text(_splitEqually ? 'Égale' : 'Personnalisée'), + Switch( + value: _splitEqually, + onChanged: (value) { + setState(() { + _splitEqually = value; + if (value) _calculateSplits(); + }); + }, + ), + ], + ), + const Divider(), + ...widget.group.members.map((member) { + final isSelected = _splits.containsKey(member.userId) && + _splits[member.userId]! >= 0; + + return CheckboxListTile( + title: Text(member.firstName), + subtitle: _splitEqually || !isSelected + ? null + : TextFormField( + initialValue: _splits[member.userId]?.toStringAsFixed(2), + decoration: const InputDecoration( + labelText: 'Montant', + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (value) { + final amount = double.tryParse(value) ?? 0; + setState(() => _splits[member.userId] = amount); + }, + ), + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _splits[member.userId] = 0; + if (_splitEqually) _calculateSplits(); + } else { + _splits[member.userId] = -1; + } + }); + }, + ); + }).toList(), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Reçu + ListTile( + leading: const Icon(Icons.receipt), + title: Text(_receiptImage != null || widget.expenseToEdit?.receiptUrl != null + ? 'Reçu ajouté' + : 'Ajouter un reçu'), + subtitle: _receiptImage != null + ? const Text('Nouveau reçu sélectionné') + : null, + trailing: IconButton( + icon: const Icon(Icons.add_photo_alternate), + onPressed: _pickImage, + ), + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 24), + + // Boutons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _submit, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.expenseToEdit == null ? 'Ajouter' : 'Modifier'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/count/balances_tab.dart b/lib/components/count/balances_tab.dart new file mode 100644 index 0000000..dfb556a --- /dev/null +++ b/lib/components/count/balances_tab.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import '../../data/models/balance.dart'; + +class BalancesTab extends StatelessWidget { + final List balances; + + const BalancesTab({ + super.key, + required this.balances, + }); + + @override + Widget build(BuildContext context) { + if (balances.isEmpty) { + return const Center( + child: Text('Aucune balance à afficher'), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: balances.length, + itemBuilder: (context, index) { + final balance = balances[index]; + return _buildBalanceCard(context, balance); + }, + ); + } + + Widget _buildBalanceCard(BuildContext context, Balance balance) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + Color balanceColor; + IconData balanceIcon; + String balanceText; + + if (balance.shouldReceive) { + balanceColor = Colors.green; + balanceIcon = Icons.arrow_downward; + balanceText = 'À recevoir'; + } else if (balance.shouldPay) { + balanceColor = Colors.red; + balanceIcon = Icons.arrow_upward; + balanceText = 'À payer'; + } else { + balanceColor = Colors.grey; + balanceIcon = Icons.check_circle; + balanceText = 'Équilibré'; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], + child: Text( + balance.userName.isNotEmpty + ? balance.userName[0].toUpperCase() + : '?', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + balance.userName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Icon(balanceIcon, size: 16, color: balanceColor), + const SizedBox(width: 4), + Text( + '${balance.absoluteBalance.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: balanceColor, + ), + ), + ], + ), + Text( + balanceText, + style: TextStyle( + fontSize: 12, + color: balanceColor, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/count/count_content.dart b/lib/components/count/count_content.dart index d9a0e3e..711a4b9 100644 --- a/lib/components/count/count_content.dart +++ b/lib/components/count/count_content.dart @@ -1,27 +1,143 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../blocs/group/group_bloc.dart'; +import '../../blocs/group/group_state.dart'; +import '../../data/models/group.dart'; +import 'group_expenses_page.dart'; class CountContent extends StatelessWidget { const CountContent({super.key}); @override Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is GroupLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is GroupLoaded) { + if (state.groups.isEmpty) { + return _buildEmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: state.groups.length, + itemBuilder: (context, index) { + final group = state.groups[index]; + return _buildGroupCard(context, group); + }, + ); + } + + if (state is GroupError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text(state.message), + ], + ), + ); + } + + return _buildEmptyState(); + }, + ); + } + + Widget _buildEmptyState() { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.account_balance_wallet, size: 64, color: Colors.green), + Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey), SizedBox(height: 16), Text( - 'Comptes et Budget', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + 'Aucun groupe', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), SizedBox(height: 8), Text( - 'Gérez vos dépenses de voyage', - style: TextStyle(fontSize: 16, color: Colors.grey), + 'Créez un groupe pour commencer à gérer vos dépenses', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), ), ], ), ); } + + Widget _buildGroupCard(BuildContext context, Group group) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupExpensesPage(group: group), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? Colors.blue[900] : Colors.blue[100], + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.group, + color: Colors.blue, + size: 32, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '${group.members.length} membre${group.members.length > 1 ? 's' : ''}', + style: TextStyle( + fontSize: 14, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/components/count/expense_detail_dialog.dart b/lib/components/count/expense_detail_dialog.dart new file mode 100644 index 0000000..bfabbb6 --- /dev/null +++ b/lib/components/count/expense_detail_dialog.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../blocs/count/count_bloc.dart'; +import '../../blocs/count/count_event.dart'; +import '../../blocs/user/user_bloc.dart'; +import '../../blocs/user/user_state.dart' as user_state; +import '../../data/models/expense.dart'; +import '../../data/models/group.dart'; +import 'add_expense_dialog.dart'; + +class ExpenseDetailDialog extends StatelessWidget { + final Expense expense; + final Group group; + + const ExpenseDetailDialog({ + super.key, + required this.expense, + required this.group, + }); + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('dd MMMM yyyy'); + final timeFormat = DateFormat('HH:mm'); + + return BlocBuilder( + builder: (context, userState) { + final currentUser = userState is user_state.UserLoaded ? userState.user : null; + final canEdit = currentUser?.id == expense.paidById; + + return Dialog( + child: Container( + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 700), + child: Scaffold( + appBar: AppBar( + title: const Text('Détails de la dépense'), + automaticallyImplyLeading: false, + actions: [ + if (canEdit) ...[ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + Navigator.of(context).pop(); + _showEditDialog(context, currentUser!); + }, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _confirmDelete(context), + ), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // En-tête avec icône + Center( + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + expense.category.icon, + size: 40, + color: Colors.blue, + ), + ), + const SizedBox(height: 16), + Text( + expense.description, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + expense.category.displayName, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Montant + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + '${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + if (expense.currency != ExpenseCurrency.eur) + Text( + '≈ ${expense.amountInEur.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Informations + _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, + 'Modifié le', + dateFormat.format(expense.editedAt!), + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Divisions + const Text( + 'Répartition', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ...expense.splits.map((split) => _buildSplitTile(context, split)), + + const SizedBox(height: 16), + + // Reçu + if (expense.receiptUrl != null) ...[ + const Divider(), + const SizedBox(height: 8), + const Text( + 'Reçu', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + expense.receiptUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Text('Erreur de chargement de l\'image'), + ); + }, + ), + ), + ], + + const SizedBox(height: 16), + + // Bouton archiver + if (!expense.isArchived && canEdit) + OutlinedButton.icon( + onPressed: () => _confirmArchive(context), + icon: const Icon(Icons.archive), + label: const Text('Archiver cette dépense'), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, size: 20, color: Colors.grey[600]), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const Spacer(), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildSplitTile(BuildContext context, ExpenseSplit split) { + return BlocBuilder( + builder: (context, userState) { + final currentUser = userState is user_state.UserLoaded ? userState.user : null; + final isCurrentUser = currentUser?.id == split.userId; + + return ListTile( + leading: CircleAvatar( + backgroundColor: split.isPaid ? Colors.green : Colors.orange, + child: Icon( + split.isPaid ? Icons.check : Icons.pending, + color: Colors.white, + size: 20, + ), + ), + title: Text( + split.userName, + style: TextStyle( + fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Text(split.isPaid ? 'Payé' : 'En attente'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${split.amount.toStringAsFixed(2)} €', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + if (!split.isPaid && isCurrentUser) ...[ + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.check_circle, color: Colors.green), + onPressed: () { + context.read().add(MarkSplitAsPaid( + groupId: expense.groupId, + expenseId: expense.id, + userId: split.userId, + )); + Navigator.of(context).pop(); + }, + ), + ], + ], + ), + ); + }, + ); + } + + void _showEditDialog(BuildContext context, user_state.UserModel currentUser) { + showDialog( + context: context, + builder: (dialogContext) => BlocProvider.value( + value: context.read(), + child: AddExpenseDialog( + group: group, + currentUser: currentUser, + expenseToEdit: expense, + ), + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Supprimer la dépense'), + content: const Text('Êtes-vous sûr de vouloir supprimer cette dépense ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () { + context.read().add(DeleteExpense( + groupId: expense.groupId, + expenseId: expense.id, + )); + Navigator.of(dialogContext).pop(); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + void _confirmArchive(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Archiver la dépense'), + content: const Text('Cette dépense sera archivée et n\'apparaîtra plus dans les calculs de balance.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () { + context.read().add(ArchiveExpense( + groupId: expense.groupId, + expenseId: expense.id, + )); + Navigator.of(dialogContext).pop(); + Navigator.of(context).pop(); + }, + child: const Text('Archiver'), + ), + ], + ), + ); + } +} diff --git a/lib/components/count/expenses_tab.dart b/lib/components/count/expenses_tab.dart new file mode 100644 index 0000000..870eed9 --- /dev/null +++ b/lib/components/count/expenses_tab.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../data/models/expense.dart'; +import '../../data/models/group.dart'; +import 'expense_detail_dialog.dart'; + +class ExpensesTab extends StatelessWidget { + final List expenses; + final Group group; + + const ExpensesTab({ + super.key, + required this.expenses, + required this.group, + }); + + @override + Widget build(BuildContext context) { + if (expenses.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, size: 80, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Aucune dépense', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Ajoutez votre première dépense', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: expenses.length, + itemBuilder: (context, index) { + final expense = expenses[index]; + return _buildExpenseCard(context, expense); + }, + ); + } + + Widget _buildExpenseCard(BuildContext context, Expense expense) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final dateFormat = DateFormat('dd/MM/yyyy'); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => _showExpenseDetail(context, expense), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isDark ? Colors.blue[900] : Colors.blue[100], + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + expense.category.icon, + color: Colors.blue, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + expense.description, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Payé par ${expense.paidByName}', + style: TextStyle( + fontSize: 14, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + Text( + dateFormat.format(expense.date), + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[500], + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + if (expense.currency != ExpenseCurrency.eur) + Text( + '${expense.amountInEur.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showExpenseDetail(BuildContext context, Expense expense) { + showDialog( + context: context, + builder: (context) => ExpenseDetailDialog( + expense: expense, + group: group, + ), + ); + } +} diff --git a/lib/components/count/group_expenses_page.dart b/lib/components/count/group_expenses_page.dart new file mode 100644 index 0000000..f54a96e --- /dev/null +++ b/lib/components/count/group_expenses_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../blocs/count/count_bloc.dart'; +import '../../blocs/count/count_event.dart'; +import '../../blocs/count/count_state.dart'; +import '../../blocs/user/user_bloc.dart'; +import '../../blocs/user/user_state.dart' as user_state; +import '../../data/models/group.dart'; +import 'add_expense_dialog.dart'; +import 'balances_tab.dart'; +import 'expenses_tab.dart'; +import 'settlements_tab.dart'; + +class GroupExpensesPage extends StatefulWidget { + final Group group; + + const GroupExpensesPage({ + super.key, + required this.group, + }); + + @override + State createState() => _GroupExpensesPageState(); +} + +class _GroupExpensesPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + context.read().add(LoadExpenses(widget.group.id)); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.group.name), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Dépenses', icon: Icon(Icons.receipt_long)), + Tab(text: 'Balances', icon: Icon(Icons.account_balance)), + Tab(text: 'Remboursements', icon: Icon(Icons.payments)), + ], + ), + ), + body: BlocConsumer( + listener: (context, state) { + if (state is CountError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + ), + ); + } + }, + builder: (context, state) { + if (state is CountLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is ExpensesLoaded) { + return TabBarView( + controller: _tabController, + children: [ + ExpensesTab( + expenses: state.expenses, + group: widget.group, + ), + BalancesTab(balances: state.balances), + SettlementsTab(settlements: state.settlements), + ], + ); + } + + return const Center(child: Text('Aucune donnée')); + }, + ), + floatingActionButton: BlocBuilder( + builder: (context, userState) { + if (userState is! user_state.UserLoaded) return const SizedBox(); + + return FloatingActionButton.extended( + onPressed: () => _showAddExpenseDialog(context, userState.user), + icon: const Icon(Icons.add), + label: const Text('Dépense'), + ); + }, + ), + ); + } + + void _showAddExpenseDialog(BuildContext context, user_state.UserModel currentUser) { + showDialog( + context: context, + builder: (dialogContext) => BlocProvider.value( + value: context.read(), + child: AddExpenseDialog( + group: widget.group, + currentUser: currentUser, + ), + ), + ); + } +} diff --git a/lib/components/count/settlements_tab.dart b/lib/components/count/settlements_tab.dart new file mode 100644 index 0000000..26de8f0 --- /dev/null +++ b/lib/components/count/settlements_tab.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import '../../data/models/balance.dart'; + +class SettlementsTab extends StatelessWidget { + final List settlements; + + const SettlementsTab({ + super.key, + required this.settlements, + }); + + @override + Widget build(BuildContext context) { + if (settlements.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, size: 80, color: Colors.green), + SizedBox(height: 16), + Text( + 'Tout est réglé !', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Aucun remboursement nécessaire', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ], + ), + ); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + color: Colors.blue.withValues(alpha: 0.1), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Plan de remboursement optimisé (${settlements.length} transaction${settlements.length > 1 ? 's' : ''})', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: settlements.length, + itemBuilder: (context, index) { + final settlement = settlements[index]; + return _buildSettlementCard(context, settlement, index + 1); + }, + ), + ), + ], + ); + } + + Widget _buildSettlementCard(BuildContext context, Settlement settlement, int number) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isDark ? Colors.blue[900] : Colors.blue[100], + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '$number', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + settlement.fromUserName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'doit payer', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: Text( + '${settlement.amount.toStringAsFixed(2)} €', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + settlement.toUserName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.right, + ), + const SizedBox(height: 4), + Text( + 'à recevoir', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/data/models/balance.dart b/lib/data/models/balance.dart new file mode 100644 index 0000000..3f0bfcd --- /dev/null +++ b/lib/data/models/balance.dart @@ -0,0 +1,34 @@ +class Balance { + final String userId; + final String userName; + final double totalPaid; + final double totalOwed; + final double balance; + + Balance({ + required this.userId, + required this.userName, + required this.totalPaid, + required this.totalOwed, + }) : balance = totalPaid - totalOwed; + + bool get shouldReceive => balance > 0; + bool get shouldPay => balance < 0; + double get absoluteBalance => balance.abs(); +} + +class Settlement { + final String fromUserId; + final String fromUserName; + final String toUserId; + final String toUserName; + final double amount; + + Settlement({ + required this.fromUserId, + required this.fromUserName, + required this.toUserId, + required this.toUserName, + required this.amount, + }); +} diff --git a/lib/data/models/expense.dart b/lib/data/models/expense.dart new file mode 100644 index 0000000..65b8ba3 --- /dev/null +++ b/lib/data/models/expense.dart @@ -0,0 +1,243 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; + +enum ExpenseCategory { + restaurant, + transport, + accommodation, + activities, + shopping, + other, +} + +enum ExpenseCurrency { + eur, + usd, + gbp, + jpy, + chf, + cad, + aud, +} + +class ExpenseSplit { + final String userId; + final String userName; + final double amount; + final bool isPaid; + + ExpenseSplit({ + required this.userId, + required this.userName, + required this.amount, + this.isPaid = false, + }); + + Map toMap() { + return { + 'userId': userId, + 'userName': userName, + 'amount': amount, + 'isPaid': isPaid, + }; + } + + factory ExpenseSplit.fromMap(Map map) { + return ExpenseSplit( + userId: map['userId'] ?? '', + userName: map['userName'] ?? '', + amount: (map['amount'] ?? 0.0).toDouble(), + isPaid: map['isPaid'] ?? false, + ); + } +} + +class Expense { + final String id; + final String groupId; + final String description; + final double amount; + final ExpenseCurrency currency; + final double amountInEur; + final ExpenseCategory category; + final String paidById; + final String paidByName; + final List splits; + final String? receiptUrl; + final DateTime date; + final DateTime createdAt; + final bool isArchived; + final bool isEdited; + final DateTime? editedAt; + + Expense({ + this.id = '', + required this.groupId, + required this.description, + required this.amount, + required this.currency, + required this.amountInEur, + required this.category, + required this.paidById, + required this.paidByName, + required this.splits, + this.receiptUrl, + required this.date, + DateTime? createdAt, + this.isArchived = false, + this.isEdited = false, + this.editedAt, + }) : createdAt = createdAt ?? DateTime.now(); + + Map toMap() { + return { + 'groupId': groupId, + 'description': description, + 'amount': amount, + 'currency': currency.name, + 'amountInEur': amountInEur, + 'category': category.name, + 'paidById': paidById, + 'paidByName': paidByName, + 'splits': splits.map((s) => s.toMap()).toList(), + 'receiptUrl': receiptUrl, + 'date': Timestamp.fromDate(date), + 'createdAt': Timestamp.fromDate(createdAt), + 'isArchived': isArchived, + 'isEdited': isEdited, + 'editedAt': editedAt != null ? Timestamp.fromDate(editedAt!) : null, + }; + } + + factory Expense.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + final editedAtTimestamp = data['editedAt'] as Timestamp?; + + return Expense( + id: doc.id, + groupId: data['groupId'] ?? '', + description: data['description'] ?? '', + amount: (data['amount'] ?? 0.0).toDouble(), + currency: ExpenseCurrency.values.firstWhere( + (e) => e.name == data['currency'], + orElse: () => ExpenseCurrency.eur, + ), + amountInEur: (data['amountInEur'] ?? 0.0).toDouble(), + category: ExpenseCategory.values.firstWhere( + (e) => e.name == data['category'], + orElse: () => ExpenseCategory.other, + ), + paidById: data['paidById'] ?? '', + paidByName: data['paidByName'] ?? '', + splits: (data['splits'] as List?) + ?.map((s) => ExpenseSplit.fromMap(s as Map)) + .toList() ?? + [], + receiptUrl: data['receiptUrl'], + date: (data['date'] as Timestamp?)?.toDate() ?? DateTime.now(), + createdAt: (data['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + isArchived: data['isArchived'] ?? false, + isEdited: data['isEdited'] ?? false, + editedAt: editedAtTimestamp?.toDate(), + ); + } + + Expense copyWith({ + String? id, + String? groupId, + String? description, + double? amount, + ExpenseCurrency? currency, + double? amountInEur, + ExpenseCategory? category, + String? paidById, + String? paidByName, + List? splits, + String? receiptUrl, + DateTime? date, + DateTime? createdAt, + bool? isArchived, + bool? isEdited, + DateTime? editedAt, + }) { + return Expense( + id: id ?? this.id, + groupId: groupId ?? this.groupId, + description: description ?? this.description, + amount: amount ?? this.amount, + currency: currency ?? this.currency, + amountInEur: amountInEur ?? this.amountInEur, + category: category ?? this.category, + paidById: paidById ?? this.paidById, + paidByName: paidByName ?? this.paidByName, + splits: splits ?? this.splits, + receiptUrl: receiptUrl ?? this.receiptUrl, + date: date ?? this.date, + createdAt: createdAt ?? this.createdAt, + isArchived: isArchived ?? this.isArchived, + isEdited: isEdited ?? this.isEdited, + editedAt: editedAt ?? this.editedAt, + ); + } +} + +extension ExpenseCategoryExtension on ExpenseCategory { + String get displayName { + switch (this) { + case ExpenseCategory.restaurant: + return 'Restaurant'; + case ExpenseCategory.transport: + return 'Transport'; + case ExpenseCategory.accommodation: + return 'Hébergement'; + case ExpenseCategory.activities: + return 'Activités'; + case ExpenseCategory.shopping: + return 'Shopping'; + case ExpenseCategory.other: + return 'Autre'; + } + } + + IconData get icon { + switch (this) { + case ExpenseCategory.restaurant: + return Icons.restaurant; + case ExpenseCategory.transport: + return Icons.directions_car; + case ExpenseCategory.accommodation: + return Icons.hotel; + case ExpenseCategory.activities: + return Icons.attractions; + case ExpenseCategory.shopping: + return Icons.shopping_bag; + case ExpenseCategory.other: + return Icons.more_horiz; + } + } +} + +extension ExpenseCurrencyExtension on ExpenseCurrency { + String get symbol { + switch (this) { + case ExpenseCurrency.eur: + return '€'; + case ExpenseCurrency.usd: + return '\$'; + case ExpenseCurrency.gbp: + return '£'; + case ExpenseCurrency.jpy: + return '¥'; + case ExpenseCurrency.chf: + return 'CHF'; + case ExpenseCurrency.cad: + return 'CAD'; + case ExpenseCurrency.aud: + return 'AUD'; + } + } + + String get code { + return name.toUpperCase(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 6ba1216..45efd8d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,11 +12,13 @@ import 'blocs/theme/theme_state.dart'; import 'blocs/group/group_bloc.dart'; import 'blocs/user/user_bloc.dart'; import 'blocs/trip/trip_bloc.dart'; +import 'blocs/count/count_bloc.dart'; import 'repositories/auth_repository.dart'; import 'repositories/trip_repository.dart'; import 'repositories/user_repository.dart'; import 'repositories/group_repository.dart'; import 'repositories/message_repository.dart'; +import 'repositories/count_repository.dart'; import 'pages/login.dart'; import 'pages/home.dart'; import 'pages/signup.dart'; @@ -52,6 +54,9 @@ class MyApp extends StatelessWidget { RepositoryProvider( create: (context) => MessageRepository(), ), + RepositoryProvider( + create: (context) => CountRepository(), + ), ], child: MultiBlocProvider( providers: [ @@ -74,6 +79,9 @@ class MyApp extends StatelessWidget { BlocProvider( create: (context) => MessageBloc(), ), + BlocProvider( + create: (context) => CountBloc(), + ), ], child: BlocBuilder( builder: (context, themeState) { diff --git a/lib/repositories/count_repository.dart b/lib/repositories/count_repository.dart new file mode 100644 index 0000000..f38dc87 --- /dev/null +++ b/lib/repositories/count_repository.dart @@ -0,0 +1,165 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'dart:io'; +import '../data/models/expense.dart'; + +class CountRepository { + final FirebaseFirestore _firestore; + final FirebaseStorage _storage; + + CountRepository({ + FirebaseFirestore? firestore, + FirebaseStorage? storage, + }) : _firestore = firestore ?? FirebaseFirestore.instance, + _storage = storage ?? FirebaseStorage.instance; + + // Créer une dépense + Future createExpense(Expense expense) async { + final docRef = await _firestore + .collection('groups') + .doc(expense.groupId) + .collection('expenses') + .add(expense.toMap()); + + return docRef.id; + } + + // Mettre à jour une dépense + Future updateExpense(String groupId, Expense expense) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expense.id) + .update(expense.toMap()); + } + + // Supprimer une dépense + Future deleteExpense(String groupId, String expenseId) async { + final expense = await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expenseId) + .get(); + + final data = expense.data(); + if (data != null && data['receiptUrl'] != null) { + await deleteReceipt(data['receiptUrl']); + } + + await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expenseId) + .delete(); + } + + // Archiver une dépense + Future archiveExpense(String groupId, String expenseId) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expenseId) + .update({'isArchived': true}); + } + + // Marquer une split comme payée + Future markSplitAsPaid({ + required String groupId, + required String expenseId, + required String userId, + }) async { + final doc = await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expenseId) + .get(); + + final expense = Expense.fromFirestore(doc); + final updatedSplits = expense.splits.map((split) { + if (split.userId == userId) { + return ExpenseSplit( + userId: split.userId, + userName: split.userName, + amount: split.amount, + isPaid: true, + ); + } + return split; + }).toList(); + + await _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .doc(expenseId) + .update({ + 'splits': updatedSplits.map((s) => s.toMap()).toList(), + }); + } + + // Stream des dépenses d'un groupe + Stream> getExpensesStream(String groupId, {bool includeArchived = false}) { + Query query = _firestore + .collection('groups') + .doc(groupId) + .collection('expenses') + .orderBy('date', descending: true); + + if (!includeArchived) { + query = query.where('isArchived', isEqualTo: false); + } + + return query.snapshots().map((snapshot) { + return snapshot.docs.map((doc) => Expense.fromFirestore(doc)).toList(); + }); + } + + // Uploader un reçu + Future uploadReceipt(String groupId, String expenseId, File imageFile) async { + final fileName = 'receipts/$groupId/$expenseId/${DateTime.now().millisecondsSinceEpoch}.jpg'; + final ref = _storage.ref().child(fileName); + + final uploadTask = await ref.putFile(imageFile); + return await uploadTask.ref.getDownloadURL(); + } + + // Supprimer un reçu + Future deleteReceipt(String receiptUrl) async { + try { + final ref = _storage.refFromURL(receiptUrl); + await ref.delete(); + } catch (e) { + // Le fichier n'existe peut-être plus + print('Erreur lors de la suppression du reçu: $e'); + } + } + + // Obtenir les taux de change (API externe ou valeurs fixes) + Future> getExchangeRates() async { + // TODO: Intégrer une API de taux de change réels + // Pour l'instant, valeurs approximatives + return { + ExpenseCurrency.eur: 1.0, + ExpenseCurrency.usd: 0.92, + ExpenseCurrency.gbp: 1.17, + ExpenseCurrency.jpy: 0.0062, + ExpenseCurrency.chf: 1.05, + ExpenseCurrency.cad: 0.68, + ExpenseCurrency.aud: 0.61, + }; + } + + // Convertir un montant en EUR + Future convertToEur(double amount, ExpenseCurrency currency) async { + if (currency == ExpenseCurrency.eur) return amount; + + final rates = await getExchangeRates(); + final rate = rates[currency] ?? 1.0; + return amount * rate; + } +} diff --git a/lib/services/count_service.dart b/lib/services/count_service.dart new file mode 100644 index 0000000..47fc04a --- /dev/null +++ b/lib/services/count_service.dart @@ -0,0 +1,226 @@ +import 'dart:io'; +import '../data/models/expense.dart'; +import '../data/models/balance.dart'; +import '../repositories/count_repository.dart'; +import 'error_service.dart'; + +class CountService { + final CountRepository _countRepository; + final ErrorService _errorService; + + CountService({ + CountRepository? countRepository, + ErrorService? errorService, + }) : _countRepository = countRepository ?? CountRepository(), + _errorService = errorService ?? ErrorService(); + + // Créer une dépense + Future createExpense(Expense expense, {File? receiptImage}) async { + try { + final expenseId = await _countRepository.createExpense(expense); + + if (receiptImage != null) { + final receiptUrl = await _countRepository.uploadReceipt( + expense.groupId, + expenseId, + receiptImage, + ); + + final updatedExpense = expense.copyWith( + id: expenseId, + receiptUrl: receiptUrl, + ); + + await _countRepository.updateExpense(expense.groupId, updatedExpense); + } + + return expenseId; + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la création de la dépense: $e'); + rethrow; + } + } + + // Mettre à jour une dépense + Future updateExpense(Expense expense, {File? newReceiptImage}) async { + try { + if (newReceiptImage != null) { + // Supprimer l'ancien reçu si existe + if (expense.receiptUrl != null) { + await _countRepository.deleteReceipt(expense.receiptUrl!); + } + + // Uploader le nouveau + final receiptUrl = await _countRepository.uploadReceipt( + expense.groupId, + expense.id, + newReceiptImage, + ); + + expense = expense.copyWith(receiptUrl: receiptUrl); + } + + await _countRepository.updateExpense(expense.groupId, expense); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la mise à jour de la dépense: $e'); + rethrow; + } + } + + // Supprimer une dépense + Future deleteExpense(String groupId, String expenseId) async { + try { + await _countRepository.deleteExpense(groupId, expenseId); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la suppression de la dépense: $e'); + rethrow; + } + } + + // Archiver une dépense + Future archiveExpense(String groupId, String expenseId) async { + try { + await _countRepository.archiveExpense(groupId, expenseId); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de l\'archivage de la dépense: $e'); + rethrow; + } + } + + // Marquer une split comme payée + Future markSplitAsPaid(String groupId, String expenseId, String userId) async { + try { + await _countRepository.markSplitAsPaid( + groupId: groupId, + expenseId: expenseId, + userId: userId, + ); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors du marquage du paiement: $e'); + rethrow; + } + } + + // Stream des dépenses + Stream> getExpensesStream(String groupId, {bool includeArchived = false}) { + try { + return _countRepository.getExpensesStream(groupId, includeArchived: includeArchived); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la récupération des dépenses: $e'); + rethrow; + } + } + + // Calculer les balances + List calculateBalances(List expenses, List memberIds, Map memberNames) { + final balances = {}; + + // Initialiser les balances + for (final memberId in memberIds) { + balances[memberId] = Balance( + userId: memberId, + userName: memberNames[memberId] ?? 'Unknown', + totalPaid: 0, + totalOwed: 0, + ); + } + + // Calculer pour chaque dépense + for (final expense in expenses) { + if (expense.isArchived) continue; + + // Ajouter au total payé + final payer = balances[expense.paidById]; + if (payer != null) { + balances[expense.paidById] = Balance( + userId: payer.userId, + userName: payer.userName, + totalPaid: payer.totalPaid + expense.amountInEur, + totalOwed: payer.totalOwed, + ); + } + + // Ajouter au total dû pour chaque split + for (final split in expense.splits) { + if (!split.isPaid) { + final debtor = balances[split.userId]; + if (debtor != null) { + balances[split.userId] = Balance( + userId: debtor.userId, + userName: debtor.userName, + totalPaid: debtor.totalPaid, + totalOwed: debtor.totalOwed + split.amount, + ); + } + } + } + } + + return balances.values.toList(); + } + + // Calculer les remboursements optimisés + List calculateOptimizedSettlements(List balances) { + final settlements = []; + + // Créer des copies mutables + final creditors = balances.where((b) => b.shouldReceive).map((b) => + {'userId': b.userId, 'userName': b.userName, 'amount': b.balance} + ).toList(); + + final debtors = balances.where((b) => b.shouldPay).map((b) => + {'userId': b.userId, 'userName': b.userName, 'amount': b.absoluteBalance} + ).toList(); + + // Trier par montant décroissant + creditors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double)); + debtors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double)); + + int i = 0, j = 0; + while (i < creditors.length && j < debtors.length) { + final creditor = creditors[i]; + final debtor = debtors[j]; + + final creditorAmount = creditor['amount'] as double; + final debtorAmount = debtor['amount'] as double; + + final settleAmount = creditorAmount < debtorAmount ? creditorAmount : debtorAmount; + + settlements.add(Settlement( + fromUserId: debtor['userId'] as String, + fromUserName: debtor['userName'] as String, + toUserId: creditor['userId'] as String, + toUserName: creditor['userName'] as String, + amount: settleAmount, + )); + + creditor['amount'] = creditorAmount - settleAmount; + debtor['amount'] = debtorAmount - settleAmount; + + if (creditor['amount'] == 0) i++; + if (debtor['amount'] == 0) j++; + } + + return settlements; + } + + // Convertir un montant en EUR + Future convertToEur(double amount, ExpenseCurrency currency) async { + try { + return await _countRepository.convertToEur(amount, currency); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la conversion: $e'); + rethrow; + } + } + + // Obtenir les taux de change + Future> getExchangeRates() async { + try { + return await _countRepository.getExchangeRates(); + } catch (e) { + _errorService.logError('count_service.dart', 'Erreur lors de la récupération des taux: $e'); + rethrow; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index b106f62..5dc8c73 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -201,6 +209,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2" + url: "https://pub.dev" + source: hosted + version: "0.9.4+5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" firebase_auth: dependency: "direct main" description: @@ -249,6 +289,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: d740b9ea0105f27d7286d7ad5957d778bf7fa967796c6e3fc26491cf5245b486 + url: "https://pub.dev" + source: hosted + version: "13.0.3" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: "52a1dfb2c93f49a8e800d4b9aed107d1c0f2f6dd3ebf10a5f7e2222442960e50" + url: "https://pub.dev" + source: hosted + version: "5.2.14" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: d5ce115e3d5a58fddd7631d5246aa8389d8000fa7790ecb04f65bf751bbf6a92 + url: "https://pub.dev" + source: hosted + version: "3.10.21" fixnum: dependency: transitive description: @@ -520,6 +584,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca" + url: "https://pub.dev" + source: hosted + version: "0.8.13+5" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58 + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a9cad4..fd27da1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,9 @@ dependencies: google_places_flutter: ^2.1.1 http: ^1.5.0 flutter_dotenv: ^6.0.0 + image_picker: ^1.2.0 + intl: ^0.20.2 + firebase_storage: ^13.0.3 dev_dependencies: flutter_launcher_icons: ^0.13.1