diff --git a/lib/blocs/account/account_bloc.dart b/lib/blocs/account/account_bloc.dart index 982e0e1..3c43162 100644 --- a/lib/blocs/account/account_bloc.dart +++ b/lib/blocs/account/account_bloc.dart @@ -4,7 +4,7 @@ import 'package:travel_mate/services/error_service.dart'; import 'account_event.dart'; import 'account_state.dart'; import '../../repositories/account_repository.dart'; -import '../../data/models/account.dart'; +import '../../models/account.dart'; class AccountBloc extends Bloc { final AccountRepository _repository; diff --git a/lib/blocs/account/account_event.dart b/lib/blocs/account/account_event.dart index 3b1b33d..0e63e7d 100644 --- a/lib/blocs/account/account_event.dart +++ b/lib/blocs/account/account_event.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/account.dart'; -import '../../data/models/group_member.dart'; +import '../../models/account.dart'; +import '../../models/group_member.dart'; abstract class AccountEvent extends Equatable { const AccountEvent(); diff --git a/lib/blocs/account/account_state.dart b/lib/blocs/account/account_state.dart index 21db115..0257566 100644 --- a/lib/blocs/account/account_state.dart +++ b/lib/blocs/account/account_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/account.dart'; +import '../../models/account.dart'; abstract class AccountState extends Equatable { const AccountState(); diff --git a/lib/blocs/auth/auth_state.dart b/lib/blocs/auth/auth_state.dart index 8806273..2897284 100644 --- a/lib/blocs/auth/auth_state.dart +++ b/lib/blocs/auth/auth_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/user.dart'; +import '../../models/user.dart'; abstract class AuthState extends Equatable { const AuthState(); diff --git a/lib/blocs/balance/balance_bloc.dart b/lib/blocs/balance/balance_bloc.dart new file mode 100644 index 0000000..168ae00 --- /dev/null +++ b/lib/blocs/balance/balance_bloc.dart @@ -0,0 +1,81 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/balance_repository.dart'; +import '../../repositories/expense_repository.dart'; +import '../../services/balance_service.dart'; +import '../../services/error_service.dart'; +import 'balance_event.dart'; +import 'balance_state.dart'; +class BalanceBloc extends Bloc { + final BalanceRepository _balanceRepository; + final BalanceService _balanceService; + final ErrorService _errorService; + + BalanceBloc({ + required BalanceRepository balanceRepository, + required ExpenseRepository expenseRepository, + BalanceService? balanceService, + ErrorService? errorService, + }) : _balanceRepository = balanceRepository, + _balanceService = balanceService ?? BalanceService(balanceRepository: balanceRepository, expenseRepository: expenseRepository), + _errorService = errorService ?? ErrorService(), + super(BalanceInitial()) { + on(_onLoadGroupBalance); + on(_onRefreshBalance); + on(_onMarkSettlementAsCompleted); + } + + Future _onLoadGroupBalance( + LoadGroupBalance event, + Emitter emit, + ) async { + try { + emit(BalanceLoading()); + + final groupBalance = await _balanceRepository.calculateGroupBalance(event.groupId); + emit(BalanceLoaded(groupBalance)); + } catch (e) { + _errorService.logError('BalanceBloc', 'Erreur chargement balance: $e'); + emit(BalanceError(e.toString())); + } + } + + Future _onRefreshBalance( + RefreshBalance event, + Emitter emit, + ) async { + try { + // Garde l'état actuel pendant le refresh si possible + if (state is! BalanceLoaded) { + emit(BalanceLoading()); + } + + final groupBalance = await _balanceRepository.calculateGroupBalance(event.groupId); + emit(BalanceLoaded(groupBalance)); + } catch (e) { + _errorService.logError('BalanceBloc', 'Erreur refresh balance: $e'); + emit(BalanceError(e.toString())); + } + } + + Future _onMarkSettlementAsCompleted( + MarkSettlementAsCompleted event, + Emitter emit, + ) async { + try { + await _balanceService.markSettlementAsCompleted( + groupId: event.groupId, + fromUserId: event.fromUserId, + toUserId: event.toUserId, + amount: event.amount, + ); + + emit(const BalanceOperationSuccess('Règlement marqué comme effectué')); + + // Recharger la balance après le règlement + add(RefreshBalance(event.groupId)); + } catch (e) { + _errorService.logError('BalanceBloc', 'Erreur mark settlement: $e'); + emit(BalanceError(e.toString())); + } + } +} \ No newline at end of file diff --git a/lib/blocs/balance/balance_event.dart b/lib/blocs/balance/balance_event.dart new file mode 100644 index 0000000..d7c2c4d --- /dev/null +++ b/lib/blocs/balance/balance_event.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +abstract class BalanceEvent extends Equatable { + const BalanceEvent(); + + @override + List get props => []; +} + +class LoadGroupBalance extends BalanceEvent { + final String groupId; + + const LoadGroupBalance(this.groupId); + + @override + List get props => [groupId]; +} + +class RefreshBalance extends BalanceEvent { + final String groupId; + + const RefreshBalance(this.groupId); + + @override + List get props => [groupId]; +} + +class MarkSettlementAsCompleted extends BalanceEvent { + final String groupId; + final String fromUserId; + final String toUserId; + final double amount; + + const MarkSettlementAsCompleted({ + required this.groupId, + required this.fromUserId, + required this.toUserId, + required this.amount, + }); + + @override + List get props => [groupId, fromUserId, toUserId, amount]; +} \ No newline at end of file diff --git a/lib/blocs/balance/balance_state.dart b/lib/blocs/balance/balance_state.dart new file mode 100644 index 0000000..f79a956 --- /dev/null +++ b/lib/blocs/balance/balance_state.dart @@ -0,0 +1,40 @@ +import 'package:equatable/equatable.dart'; +import '../../models/group_balance.dart'; + +abstract class BalanceState extends Equatable { + const BalanceState(); + + @override + List get props => []; +} + +class BalanceInitial extends BalanceState {} + +class BalanceLoading extends BalanceState {} + +class BalanceLoaded extends BalanceState { + final GroupBalance groupBalance; + + const BalanceLoaded(this.groupBalance); + + @override + List get props => [groupBalance]; +} + +class BalanceOperationSuccess extends BalanceState { + final String message; + + const BalanceOperationSuccess(this.message); + + @override + List get props => [message]; +} + +class BalanceError extends BalanceState { + final String message; + + const BalanceError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/blocs/expense/expense_bloc.dart b/lib/blocs/expense/expense_bloc.dart new file mode 100644 index 0000000..6fc7875 --- /dev/null +++ b/lib/blocs/expense/expense_bloc.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/expense_repository.dart'; +import '../../services/expense_service.dart'; +import '../../services/error_service.dart'; +import 'expense_event.dart'; +import 'expense_state.dart'; + + +class ExpenseBloc extends Bloc { + final ExpenseRepository _expenseRepository; + final ExpenseService _expenseService; + final ErrorService _errorService; + StreamSubscription? _expensesSubscription; + + ExpenseBloc({ + required ExpenseRepository expenseRepository, + ExpenseService? expenseService, + ErrorService? errorService, + }) : _expenseRepository = expenseRepository, + _expenseService = expenseService ?? ExpenseService(expenseRepository: expenseRepository), + _errorService = errorService ?? ErrorService(), + super(ExpenseInitial()) { + + on(_onLoadExpensesByGroup); + on(_onExpensesUpdated); + on(_onCreateExpense); + on(_onUpdateExpense); + on(_onDeleteExpense); + on(_onMarkSplitAsPaid); + on(_onArchiveExpense); + } + + Future _onLoadExpensesByGroup( + LoadExpensesByGroup event, + Emitter emit, + ) async { + try { + emit(ExpenseLoading()); + + await _expensesSubscription?.cancel(); + + _expensesSubscription = _expenseRepository + .getExpensesStream(event.groupId) + .listen( + (expenses) => add(ExpensesUpdated(expenses)), + onError: (error) => add(ExpensesUpdated([], error: error.toString())), + ); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur chargement expenses: $e'); + emit(ExpenseError(e.toString())); + } + } + + Future _onExpensesUpdated( + ExpensesUpdated event, + Emitter emit, + ) async { + if (event.error != null) { + emit(ExpenseError(event.error!)); + } else { + emit(ExpensesLoaded(expenses: event.expenses)); + } + } + + Future _onCreateExpense( + CreateExpense event, + Emitter emit, + ) async { + try { + await _expenseService.createExpenseWithValidation(event.expense, event.receiptImage); + emit(const ExpenseOperationSuccess('Dépense créée avec succès')); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur création expense: $e'); + emit(ExpenseError(e.toString())); + } + } + + Future _onUpdateExpense( + UpdateExpense event, + Emitter emit, + ) async { + try { + await _expenseService.updateExpenseWithValidation(event.expense, event.newReceiptImage); + emit(const ExpenseOperationSuccess('Dépense modifiée avec succès')); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur mise à jour expense: $e'); + emit(ExpenseError(e.toString())); + } + } + + Future _onDeleteExpense( + DeleteExpense event, + Emitter emit, + ) async { + try { + await _expenseRepository.deleteExpense(event.expenseId); + emit(const ExpenseOperationSuccess('Dépense supprimée avec succès')); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur suppression expense: $e'); + emit(ExpenseError(e.toString())); + } + } + + Future _onMarkSplitAsPaid( + MarkSplitAsPaid event, + Emitter emit, + ) async { + try { + await _expenseRepository.markSplitAsPaid(event.expenseId, event.userId); + emit(const ExpenseOperationSuccess('Paiement marqué comme effectué')); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur mark split paid: $e'); + emit(ExpenseError(e.toString())); + } + } + + Future _onArchiveExpense( + ArchiveExpense event, + Emitter emit, + ) async { + try { + await _expenseRepository.archiveExpense(event.expenseId); + emit(const ExpenseOperationSuccess('Dépense archivée avec succès')); + } catch (e) { + _errorService.logError('ExpenseBloc', 'Erreur archivage expense: $e'); + emit(ExpenseError(e.toString())); + } + } + + @override + Future close() { + _expensesSubscription?.cancel(); + return super.close(); + } +} \ No newline at end of file diff --git a/lib/blocs/expense/expense_event.dart b/lib/blocs/expense/expense_event.dart new file mode 100644 index 0000000..d04d253 --- /dev/null +++ b/lib/blocs/expense/expense_event.dart @@ -0,0 +1,87 @@ +import 'package:equatable/equatable.dart'; +import '../../models/expense.dart'; +import 'dart:io'; + +abstract class ExpenseEvent extends Equatable { + const ExpenseEvent(); + + @override + List get props => []; +} + +class LoadExpensesByGroup extends ExpenseEvent { + final String groupId; + + const LoadExpensesByGroup(this.groupId); + + @override + List get props => [groupId]; +} + +class CreateExpense extends ExpenseEvent { + final Expense expense; + final File? receiptImage; + + const CreateExpense({ + required this.expense, + this.receiptImage, + }); + + @override + List get props => [expense, receiptImage]; +} + +class UpdateExpense extends ExpenseEvent { + final Expense expense; + final File? newReceiptImage; + + const UpdateExpense({ + required this.expense, + this.newReceiptImage, + }); + + @override + List get props => [expense, newReceiptImage]; +} + +class DeleteExpense extends ExpenseEvent { + final String expenseId; + + const DeleteExpense(this.expenseId); + + @override + List get props => [expenseId]; +} + +class MarkSplitAsPaid extends ExpenseEvent { + final String expenseId; + final String userId; + + const MarkSplitAsPaid({ + required this.expenseId, + required this.userId, + }); + + @override + List get props => [expenseId, userId]; +} + +class ArchiveExpense extends ExpenseEvent { + final String expenseId; + + const ArchiveExpense(this.expenseId); + + @override + List get props => [expenseId]; +} + +// Événement privé pour les mises à jour du stream +class ExpensesUpdated extends ExpenseEvent { + final List expenses; + final String? error; + + const ExpensesUpdated(this.expenses, {this.error}); + + @override + List get props => [expenses, error]; +} diff --git a/lib/blocs/expense/expense_state.dart b/lib/blocs/expense/expense_state.dart new file mode 100644 index 0000000..ca00940 --- /dev/null +++ b/lib/blocs/expense/expense_state.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; +import '../../models/expense.dart'; + +abstract class ExpenseState extends Equatable { + const ExpenseState(); + + @override + List get props => []; +} + +class ExpenseInitial extends ExpenseState {} + +class ExpenseLoading extends ExpenseState {} + +class ExpensesLoaded extends ExpenseState { + final List expenses; + final Map exchangeRates; + + const ExpensesLoaded({ + required this.expenses, + this.exchangeRates = const {'EUR': 1.0, 'USD': 0.85, 'GBP': 1.15}, + }); + + @override + List get props => [expenses, exchangeRates]; +} + +class ExpenseOperationSuccess extends ExpenseState { + final String message; + + const ExpenseOperationSuccess(this.message); + + @override + List get props => [message]; +} + +class ExpenseError extends ExpenseState { + final String message; + + const ExpenseError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/blocs/group/group_bloc.dart b/lib/blocs/group/group_bloc.dart index be1778c..b7e20ac 100644 --- a/lib/blocs/group/group_bloc.dart +++ b/lib/blocs/group/group_bloc.dart @@ -4,7 +4,7 @@ import 'package:travel_mate/services/error_service.dart'; import 'group_event.dart'; import 'group_state.dart'; import '../../repositories/group_repository.dart'; -import '../../data/models/group.dart'; +import '../../models/group.dart'; class GroupBloc extends Bloc { final GroupRepository _repository; diff --git a/lib/blocs/group/group_event.dart b/lib/blocs/group/group_event.dart index 237893a..605344a 100644 --- a/lib/blocs/group/group_event.dart +++ b/lib/blocs/group/group_event.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/group.dart'; -import '../../data/models/group_member.dart'; +import '../../models/group.dart'; +import '../../models/group_member.dart'; abstract class GroupEvent extends Equatable { const GroupEvent(); diff --git a/lib/blocs/group/group_state.dart b/lib/blocs/group/group_state.dart index ea122aa..488cf07 100644 --- a/lib/blocs/group/group_state.dart +++ b/lib/blocs/group/group_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/group.dart'; +import '../../models/group.dart'; abstract class GroupState extends Equatable { const GroupState(); diff --git a/lib/blocs/message/message_bloc.dart b/lib/blocs/message/message_bloc.dart index 37eb016..59f9cec 100644 --- a/lib/blocs/message/message_bloc.dart +++ b/lib/blocs/message/message_bloc.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/models/message.dart'; +import '../../models/message.dart'; import '../../services/message_service.dart'; import '../../repositories/message_repository.dart'; import 'message_event.dart'; diff --git a/lib/blocs/message/message_state.dart b/lib/blocs/message/message_state.dart index 597911e..5d6a89f 100644 --- a/lib/blocs/message/message_state.dart +++ b/lib/blocs/message/message_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/message.dart'; +import '../../models/message.dart'; abstract class MessageState extends Equatable { const MessageState(); diff --git a/lib/blocs/trip/trip_bloc.dart b/lib/blocs/trip/trip_bloc.dart index f214190..61963b9 100644 --- a/lib/blocs/trip/trip_bloc.dart +++ b/lib/blocs/trip/trip_bloc.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:travel_mate/data/models/trip.dart'; +import 'package:travel_mate/models/trip.dart'; import 'trip_event.dart'; import 'trip_state.dart'; import '../../repositories/trip_repository.dart'; diff --git a/lib/blocs/trip/trip_event.dart b/lib/blocs/trip/trip_event.dart index 19b2eff..cda58a0 100644 --- a/lib/blocs/trip/trip_event.dart +++ b/lib/blocs/trip/trip_event.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/trip.dart'; +import '../../models/trip.dart'; abstract class TripEvent extends Equatable { const TripEvent(); diff --git a/lib/blocs/trip/trip_state.dart b/lib/blocs/trip/trip_state.dart index 203707b..021bd79 100644 --- a/lib/blocs/trip/trip_state.dart +++ b/lib/blocs/trip/trip_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../data/models/trip.dart'; +import '../../models/trip.dart'; abstract class TripState extends Equatable { const TripState(); diff --git a/lib/components/account/account_content.dart b/lib/components/account/account_content.dart index 9b170d5..8a530df 100644 --- a/lib/components/account/account_content.dart +++ b/lib/components/account/account_content.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:travel_mate/blocs/user/user_bloc.dart'; -import '../../data/models/account.dart'; +import '../../models/account.dart'; import '../../blocs/account/account_bloc.dart'; import '../../blocs/account/account_event.dart'; import '../../blocs/account/account_state.dart'; diff --git a/lib/components/account/add_expense_dialog.dart b/lib/components/account/add_expense_dialog.dart index 4f00106..0d03254 100644 --- a/lib/components/account/add_expense_dialog.dart +++ b/lib/components/account/add_expense_dialog.dart @@ -3,12 +3,13 @@ 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/account/account_bloc.dart'; -import '../../blocs/account/account_event.dart'; -import '../../blocs/account/account_state.dart'; +import 'package:travel_mate/models/expense_split.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 '../../data/models/group.dart'; -import '../../data/models/expense.dart'; +import '../../models/group.dart'; +import '../../models/expense.dart'; class AddExpenseDialog extends StatefulWidget { final Group group; @@ -148,8 +149,8 @@ class _AddExpenseDialogState extends State { try { // Convertir en EUR - final amountInEur = context.read().state is ExpensesLoaded - ? (context.read().state as ExpensesLoaded) + final amountInEur = context.read().state is ExpensesLoaded + ? (context.read().state as ExpensesLoaded) .exchangeRates[_selectedCurrency]! * amount : amount; @@ -168,15 +169,16 @@ class _AddExpenseDialogState extends State { splits: selectedSplits, date: _selectedDate, receiptUrl: widget.expenseToEdit?.receiptUrl, + createdAt: widget.expenseToEdit?.createdAt ?? DateTime.now(), ); if (widget.expenseToEdit == null) { - context.read().add(CreateExpense( + context.read().add(CreateExpense( expense: expense, receiptImage: _receiptImage, )); } else { - context.read().add(UpdateExpense( + context.read().add(UpdateExpense( expense: expense, newReceiptImage: _receiptImage, )); diff --git a/lib/components/account/balances_tab.dart b/lib/components/account/balances_tab.dart index dfb556a..4fa5872 100644 --- a/lib/components/account/balances_tab.dart +++ b/lib/components/account/balances_tab.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import '../../data/models/balance.dart'; +import '../../models/user_balance.dart'; class BalancesTab extends StatelessWidget { - final List balances; + final List balances; const BalancesTab({ super.key, @@ -27,7 +27,7 @@ class BalancesTab extends StatelessWidget { ); } - Widget _buildBalanceCard(BuildContext context, Balance balance) { + Widget _buildBalanceCard(BuildContext context, UserBalance balance) { final isDark = Theme.of(context).brightness == Brightness.dark; Color balanceColor; diff --git a/lib/components/account/expense_detail_dialog.dart b/lib/components/account/expense_detail_dialog.dart index d1a30b2..e9390a4 100644 --- a/lib/components/account/expense_detail_dialog.dart +++ b/lib/components/account/expense_detail_dialog.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; -import '../../blocs/account/account_bloc.dart'; -import '../../blocs/account/account_event.dart'; +import 'package:travel_mate/models/expense_split.dart'; +import '../../blocs/expense/expense_bloc.dart'; +import '../../blocs/expense/expense_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 '../../models/expense.dart'; +import '../../models/group.dart'; import 'add_expense_dialog.dart'; class ExpenseDetailDialog extends StatelessWidget { @@ -267,8 +268,7 @@ class ExpenseDetailDialog extends StatelessWidget { IconButton( icon: const Icon(Icons.check_circle, color: Colors.green), onPressed: () { - context.read().add(MarkSplitAsPaid( - groupId: expense.groupId, + context.read().add(MarkSplitAsPaid( expenseId: expense.id, userId: split.userId, )); @@ -287,7 +287,7 @@ class ExpenseDetailDialog extends StatelessWidget { showDialog( context: context, builder: (dialogContext) => BlocProvider.value( - value: context.read(), + value: context.read(), child: AddExpenseDialog( group: group, currentUser: currentUser, @@ -310,9 +310,8 @@ class ExpenseDetailDialog extends StatelessWidget { ), TextButton( onPressed: () { - context.read().add(DeleteExpense( - groupId: expense.groupId, - expenseId: expense.id, + context.read().add(DeleteExpense( + expense.id, )); Navigator.of(dialogContext).pop(); Navigator.of(context).pop(); @@ -338,9 +337,8 @@ class ExpenseDetailDialog extends StatelessWidget { ), TextButton( onPressed: () { - context.read().add(ArchiveExpense( - groupId: expense.groupId, - expenseId: expense.id, + context.read().add(ArchiveExpense( + expense.id, )); Navigator.of(dialogContext).pop(); Navigator.of(context).pop(); diff --git a/lib/components/account/expenses_tab.dart b/lib/components/account/expenses_tab.dart index 870eed9..2270436 100644 --- a/lib/components/account/expenses_tab.dart +++ b/lib/components/account/expenses_tab.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import '../../data/models/expense.dart'; -import '../../data/models/group.dart'; +import '../../models/expense.dart'; +import '../../models/group.dart'; import 'expense_detail_dialog.dart'; class ExpensesTab extends StatelessWidget { diff --git a/lib/components/account/settlements_tab.dart b/lib/components/account/settlements_tab.dart index 26de8f0..1cdeb65 100644 --- a/lib/components/account/settlements_tab.dart +++ b/lib/components/account/settlements_tab.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../data/models/balance.dart'; +import '../../models/settlement.dart'; class SettlementsTab extends StatelessWidget { final List settlements; diff --git a/lib/components/group/chat_group_content.dart b/lib/components/group/chat_group_content.dart index 043df55..7ca3219 100644 --- a/lib/components/group/chat_group_content.dart +++ b/lib/components/group/chat_group_content.dart @@ -5,8 +5,8 @@ import '../../blocs/user/user_state.dart' as user_state; import '../../blocs/message/message_bloc.dart'; import '../../blocs/message/message_event.dart'; import '../../blocs/message/message_state.dart'; -import '../../data/models/group.dart'; -import '../../data/models/message.dart'; +import '../../models/group.dart'; +import '../../models/message.dart'; class ChatGroupContent extends StatefulWidget { final Group group; diff --git a/lib/components/group/group_content.dart b/lib/components/group/group_content.dart index dc77fb9..04344f1 100644 --- a/lib/components/group/group_content.dart +++ b/lib/components/group/group_content.dart @@ -7,7 +7,7 @@ import '../../blocs/user/user_state.dart' as user_state; import '../../blocs/group/group_bloc.dart'; import '../../blocs/group/group_state.dart'; import '../../blocs/group/group_event.dart'; -import '../../data/models/group.dart'; +import '../../models/group.dart'; class GroupContent extends StatefulWidget { const GroupContent({super.key}); diff --git a/lib/components/home/create_trip_content.dart b/lib/components/home/create_trip_content.dart index 15db114..cbd03c6 100644 --- a/lib/components/home/create_trip_content.dart +++ b/lib/components/home/create_trip_content.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:travel_mate/data/models/trip.dart'; +import 'package:travel_mate/models/trip.dart'; import 'package:travel_mate/services/error_service.dart'; import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_state.dart' as user_state; @@ -9,8 +9,8 @@ import '../../blocs/trip/trip_event.dart'; import '../../blocs/trip/trip_state.dart'; import '../../blocs/group/group_bloc.dart'; import '../../blocs/group/group_event.dart'; -import '../../data/models/group.dart'; -import '../../data/models/group_member.dart'; +import '../../models/group.dart'; +import '../../models/group_member.dart'; import '../../services/user_service.dart'; import '../../repositories/group_repository.dart'; diff --git a/lib/components/home/home_content.dart b/lib/components/home/home_content.dart index c5594b9..7391ba5 100644 --- a/lib/components/home/home_content.dart +++ b/lib/components/home/home_content.dart @@ -7,7 +7,7 @@ import '../../blocs/user/user_state.dart'; import '../../blocs/trip/trip_bloc.dart'; import '../../blocs/trip/trip_state.dart'; import '../../blocs/trip/trip_event.dart'; -import '../../data/models/trip.dart'; +import '../../models/trip.dart'; class HomeContent extends StatefulWidget { const HomeContent({super.key}); diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index b730a9b..90d82cb 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -5,7 +5,7 @@ import 'package:travel_mate/blocs/group/group_event.dart'; import 'package:travel_mate/blocs/trip/trip_bloc.dart'; import 'package:travel_mate/blocs/trip/trip_event.dart'; import 'package:travel_mate/components/home/create_trip_content.dart'; -import 'package:travel_mate/data/models/trip.dart'; +import 'package:travel_mate/models/trip.dart'; class ShowTripDetailsContent extends StatefulWidget { final Trip trip; diff --git a/lib/data/models/balance.dart b/lib/data/models/balance.dart deleted file mode 100644 index 3f0bfcd..0000000 --- a/lib/data/models/balance.dart +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 65b8ba3..0000000 --- a/lib/data/models/expense.dart +++ /dev/null @@ -1,243 +0,0 @@ -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 13a5030..62ef4e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:travel_mate/blocs/balance/balance_bloc.dart'; +import 'package:travel_mate/blocs/expense/expense_bloc.dart'; import 'package:travel_mate/blocs/message/message_bloc.dart'; +import 'package:travel_mate/services/balance_service.dart'; import 'package:travel_mate/services/error_service.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:travel_mate/services/expense_service.dart'; import 'blocs/auth/auth_bloc.dart'; import 'blocs/auth/auth_event.dart'; import 'blocs/theme/theme_bloc.dart'; @@ -19,6 +23,8 @@ import 'repositories/user_repository.dart'; import 'repositories/group_repository.dart'; import 'repositories/message_repository.dart'; import 'repositories/account_repository.dart'; +import 'repositories/expense_repository.dart'; +import 'repositories/balance_repository.dart'; import 'pages/login.dart'; import 'pages/home.dart'; import 'pages/signup.dart'; @@ -54,6 +60,17 @@ class MyApp extends StatelessWidget { RepositoryProvider( create: (context) => MessageRepository(), ), + RepositoryProvider( + create: (context) => AccountRepository(), + ), + RepositoryProvider( + create: (context) => ExpenseRepository(), + ), + RepositoryProvider( + create: (context) => BalanceRepository( + expenseRepository: context.read(), + ), + ), ], child: MultiBlocProvider( @@ -76,7 +93,27 @@ class MyApp extends StatelessWidget { BlocProvider(create: (context) => UserBloc()), BlocProvider( create: (context) => MessageBloc(), + ), + BlocProvider( + create: (context) => AccountBloc( + context.read(), ), + ), + + // Nouveaux blocs + BlocProvider( + create: (context) => ExpenseBloc( + expenseRepository: context.read(), + expenseService: context.read(), + ), + ), + BlocProvider( + create: (context) => BalanceBloc( + balanceRepository: context.read(), + balanceService: context.read(), + expenseRepository: context.read(), + ), + ), ], child: BlocBuilder( diff --git a/lib/data/models/account.dart b/lib/models/account.dart similarity index 100% rename from lib/data/models/account.dart rename to lib/models/account.dart diff --git a/lib/models/expense.dart b/lib/models/expense.dart new file mode 100644 index 0000000..a425b0b --- /dev/null +++ b/lib/models/expense.dart @@ -0,0 +1,205 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'expense_split.dart'; + +enum ExpenseCurrency { + eur('€', 'EUR'), + usd('\$', 'USD'), + gbp('£', 'GBP'); + + const ExpenseCurrency(this.symbol, this.code); + final String symbol; + final String code; +} + +enum ExpenseCategory { + restaurant('Restaurant', Icons.restaurant), + transport('Transport', Icons.directions_car), + accommodation('Hébergement', Icons.hotel), + entertainment('Loisirs', Icons.local_activity), + shopping('Shopping', Icons.shopping_bag), + other('Autre', Icons.category); + + const ExpenseCategory(this.displayName, this.icon); + final String displayName; + final IconData icon; +} + +class Expense extends Equatable { + final String id; + final String groupId; + final String description; + final double amount; + final ExpenseCurrency currency; + final double amountInEur; // Montant converti en EUR + final ExpenseCategory category; + final String paidById; + final String paidByName; + final DateTime date; + final DateTime createdAt; + final DateTime? editedAt; + final bool isEdited; + final bool isArchived; + final String? receiptUrl; + final List splits; + + const Expense({ + required 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.date, + required this.createdAt, + this.editedAt, + this.isEdited = false, + this.isArchived = false, + this.receiptUrl, + required this.splits, + }); + + factory Expense.fromMap(Map map, String id) { + return Expense( + id: id, + groupId: map['groupId'] ?? '', + description: map['description'] ?? '', + amount: (map['amount'] as num?)?.toDouble() ?? 0.0, + currency: ExpenseCurrency.values.firstWhere( + (c) => c.code == map['currency'], + orElse: () => ExpenseCurrency.eur, + ), + amountInEur: (map['amountInEur'] as num?)?.toDouble() ?? 0.0, + category: ExpenseCategory.values.firstWhere( + (c) => c.name == map['category'], + orElse: () => ExpenseCategory.other, + ), + paidById: map['paidById'] ?? '', + paidByName: map['paidByName'] ?? '', + date: _parseDateTime(map['date']), + createdAt: _parseDateTime(map['createdAt']), + editedAt: map['editedAt'] != null ? _parseDateTime(map['editedAt']) : null, + isEdited: map['isEdited'] ?? false, + isArchived: map['isArchived'] ?? false, + receiptUrl: map['receiptUrl'], + splits: (map['splits'] as List?) + ?.map((s) => ExpenseSplit.fromMap(s)) + .toList() ?? [], + ); + } + + Map toMap() { + return { + 'groupId': groupId, + 'description': description, + 'amount': amount, + 'currency': currency.code, + 'amountInEur': amountInEur, + 'category': category.name, + 'paidById': paidById, + 'paidByName': paidByName, + 'date': Timestamp.fromDate(date), + 'createdAt': Timestamp.fromDate(createdAt), + 'editedAt': editedAt != null ? Timestamp.fromDate(editedAt!) : null, + 'isEdited': isEdited, + 'isArchived': isArchived, + 'receiptUrl': receiptUrl, + 'splits': splits.map((s) => s.toMap()).toList(), + }; + } + + static DateTime _parseDateTime(dynamic value) { + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.parse(value); + if (value is DateTime) return value; + return DateTime.now(); + } + + Expense copyWith({ + String? id, + String? groupId, + String? description, + double? amount, + ExpenseCurrency? currency, + double? amountInEur, + ExpenseCategory? category, + String? paidById, + String? paidByName, + DateTime? date, + DateTime? createdAt, + DateTime? editedAt, + bool? isEdited, + bool? isArchived, + String? receiptUrl, + List? splits, + }) { + 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, + date: date ?? this.date, + createdAt: createdAt ?? this.createdAt, + editedAt: editedAt ?? this.editedAt, + isEdited: isEdited ?? this.isEdited, + isArchived: isArchived ?? this.isArchived, + receiptUrl: receiptUrl ?? this.receiptUrl, + splits: splits ?? this.splits, + ); + } + + Expense copyWithEdit({ + String? description, + double? amount, + ExpenseCurrency? currency, + double? amountInEur, + ExpenseCategory? category, + List? splits, + String? receiptUrl, + }) { + return copyWith( + description: description, + amount: amount, + currency: currency, + amountInEur: amountInEur, + category: category, + splits: splits, + receiptUrl: receiptUrl, + editedAt: DateTime.now(), + isEdited: true, + ); + } + + // Marquer comme archivé + Expense copyWithArchived() { + return copyWith( + isArchived: true, + ); + } + + // Ajouter/mettre à jour l'URL du reçu + Expense copyWithReceipt(String receiptUrl) { + return copyWith( + receiptUrl: receiptUrl, + ); + } + + // Mettre à jour les splits + Expense copyWithSplits(List newSplits) { + return copyWith( + splits: newSplits, + ); + } + + @override + List get props => [id]; +} \ No newline at end of file diff --git a/lib/models/expense_split.dart b/lib/models/expense_split.dart new file mode 100644 index 0000000..2f71d38 --- /dev/null +++ b/lib/models/expense_split.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; + +class ExpenseSplit extends Equatable { + final String userId; + final String userName; + final double amount; + final bool isPaid; + final DateTime? paidAt; + + const ExpenseSplit({ + required this.userId, + required this.userName, + required this.amount, + this.isPaid = false, + this.paidAt, + }); + + factory ExpenseSplit.fromMap(Map map) { + return ExpenseSplit( + userId: map['userId'] ?? '', + userName: map['userName'] ?? '', + amount: (map['amount'] as num?)?.toDouble() ?? 0.0, + isPaid: map['isPaid'] ?? false, + paidAt: map['paidAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['paidAt']) + : null, + ); + } + + Map toMap() { + return { + 'userId': userId, + 'userName': userName, + 'amount': amount, + 'isPaid': isPaid, + 'paidAt': paidAt?.millisecondsSinceEpoch, + }; + } + + ExpenseSplit copyWith({ + String? userId, + String? userName, + double? amount, + bool? isPaid, + DateTime? paidAt, + }) { + return ExpenseSplit( + userId: userId ?? this.userId, + userName: userName ?? this.userName, + amount: amount ?? this.amount, + isPaid: isPaid ?? this.isPaid, + paidAt: paidAt ?? this.paidAt, + ); + } + + @override + List get props => [userId, userName, amount, isPaid]; +} \ No newline at end of file diff --git a/lib/data/models/group.dart b/lib/models/group.dart similarity index 100% rename from lib/data/models/group.dart rename to lib/models/group.dart diff --git a/lib/models/group_balance.dart b/lib/models/group_balance.dart new file mode 100644 index 0000000..1d121ec --- /dev/null +++ b/lib/models/group_balance.dart @@ -0,0 +1,93 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:equatable/equatable.dart'; +import 'user_balance.dart'; +import 'settlement.dart'; + +class GroupBalance extends Equatable { + final String groupId; + final List userBalances; + final List settlements; + final double totalExpenses; + final DateTime calculatedAt; + + const GroupBalance({ + required this.groupId, + required this.userBalances, + required this.settlements, + required this.totalExpenses, + required this.calculatedAt, + }); + + // Constructeur factory pour créer depuis une Map + factory GroupBalance.fromMap(Map map) { + return GroupBalance( + groupId: map['groupId'] ?? '', + userBalances: (map['userBalances'] as List?) + ?.map((userBalance) => UserBalance.fromMap(userBalance as Map)) + .toList() ?? [], + settlements: (map['settlements'] as List?) + ?.map((settlement) => Settlement.fromMap(settlement as Map)) + .toList() ?? [], + totalExpenses: (map['totalExpenses'] as num?)?.toDouble() ?? 0.0, + calculatedAt: _parseDateTime(map['calculatedAt']), + ); + } + + // Convertir en Map pour Firestore + Map toMap() { + return { + 'groupId': groupId, + 'userBalances': userBalances.map((userBalance) => userBalance.toMap()).toList(), + 'settlements': settlements.map((settlement) => settlement.toMap()).toList(), + 'totalExpenses': totalExpenses, + 'calculatedAt': Timestamp.fromDate(calculatedAt), + }; + } + + // Méthode copyWith pour créer une copie modifiée + GroupBalance copyWith({ + String? groupId, + List? userBalances, + List? settlements, + double? totalExpenses, + DateTime? calculatedAt, + }) { + return GroupBalance( + groupId: groupId ?? this.groupId, + userBalances: userBalances ?? this.userBalances, + settlements: settlements ?? this.settlements, + totalExpenses: totalExpenses ?? this.totalExpenses, + calculatedAt: calculatedAt ?? this.calculatedAt, + ); + } + + // Helper pour parser les dates de différents formats + static DateTime _parseDateTime(dynamic value) { + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.parse(value); + if (value is DateTime) return value; + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + return DateTime.now(); + } + + // Méthodes utilitaires pour la logique métier + bool get hasUnbalancedUsers => userBalances.any((balance) => !balance.isBalanced); + + bool get hasSettlements => settlements.isNotEmpty; + + double get totalSettlementAmount => settlements.fold(0.0, (sum, settlement) => sum + settlement.amount); + + List get creditors => userBalances.where((b) => b.shouldReceive).toList(); + + List get debtors => userBalances.where((b) => b.shouldPay).toList(); + + int get participantCount => userBalances.length; + + @override + List get props => [groupId, calculatedAt]; + + @override + String toString() { + return 'GroupBalance(groupId: $groupId, totalExpenses: $totalExpenses, participantCount: $participantCount, calculatedAt: $calculatedAt)'; + } +} \ No newline at end of file diff --git a/lib/data/models/group_member.dart b/lib/models/group_member.dart similarity index 100% rename from lib/data/models/group_member.dart rename to lib/models/group_member.dart diff --git a/lib/models/group_statistics.dart b/lib/models/group_statistics.dart new file mode 100644 index 0000000..fe83430 --- /dev/null +++ b/lib/models/group_statistics.dart @@ -0,0 +1,25 @@ +class GroupStatistics { + final double totalExpenses; + final int expenseCount; + final double averageExpense; + final String topCategory; + final Map categoryBreakdown; + + const GroupStatistics({ + required this.totalExpenses, + required this.expenseCount, + required this.averageExpense, + required this.topCategory, + required this.categoryBreakdown, + }); + + factory GroupStatistics.empty() { + return const GroupStatistics( + totalExpenses: 0.0, + expenseCount: 0, + averageExpense: 0.0, + topCategory: '', + categoryBreakdown: {}, + ); + } +} \ No newline at end of file diff --git a/lib/data/models/message.dart b/lib/models/message.dart similarity index 100% rename from lib/data/models/message.dart rename to lib/models/message.dart diff --git a/lib/models/settlement.dart b/lib/models/settlement.dart new file mode 100644 index 0000000..ec9de30 --- /dev/null +++ b/lib/models/settlement.dart @@ -0,0 +1,130 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:equatable/equatable.dart'; + +class Settlement extends Equatable { + final String fromUserId; + final String fromUserName; + final String toUserId; + final String toUserName; + final double amount; + final bool isCompleted; + final DateTime? completedAt; + + const Settlement({ + required this.fromUserId, + required this.fromUserName, + required this.toUserId, + required this.toUserName, + required this.amount, + this.isCompleted = false, + this.completedAt, + }); + + // Constructeur factory pour créer depuis une Map + factory Settlement.fromMap(Map map) { + return Settlement( + fromUserId: map['fromUserId'] ?? '', + fromUserName: map['fromUserName'] ?? '', + toUserId: map['toUserId'] ?? '', + toUserName: map['toUserName'] ?? '', + amount: (map['amount'] as num?)?.toDouble() ?? 0.0, + isCompleted: map['isCompleted'] ?? false, + completedAt: map['completedAt'] != null + ? _parseDateTime(map['completedAt']) + : null, + ); + } + + // Convertir en Map pour la sérialisation + Map toMap() { + return { + 'fromUserId': fromUserId, + 'fromUserName': fromUserName, + 'toUserId': toUserId, + 'toUserName': toUserName, + 'amount': amount, + 'isCompleted': isCompleted, + 'completedAt': completedAt != null + ? Timestamp.fromDate(completedAt!) + : null, + }; + } + + // Méthode copyWith étendue pour créer une copie modifiée + Settlement copyWith({ + String? fromUserId, + String? fromUserName, + String? toUserId, + String? toUserName, + double? amount, + bool? isCompleted, + DateTime? completedAt, + }) { + return Settlement( + fromUserId: fromUserId ?? this.fromUserId, + fromUserName: fromUserName ?? this.fromUserName, + toUserId: toUserId ?? this.toUserId, + toUserName: toUserName ?? this.toUserName, + amount: amount ?? this.amount, + isCompleted: isCompleted ?? this.isCompleted, + completedAt: completedAt ?? this.completedAt, + ); + } + + // Constructeur factory pour marquer comme complété + factory Settlement.completed(Settlement settlement) { + return settlement.copyWith( + isCompleted: true, + completedAt: DateTime.now(), + ); + } + + // Helper pour parser les dates de différents formats + static DateTime _parseDateTime(dynamic value) { + if (value is Timestamp) return value.toDate(); + if (value is String) return DateTime.parse(value); + if (value is DateTime) return value; + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + return DateTime.now(); + } + + // Getters utilitaires pour la logique métier + bool get isPending => !isCompleted; + + String get formattedAmount => '${amount.toStringAsFixed(2)} €'; + + String get description => '$fromUserName doit ${formattedAmount} à $toUserName'; + + String get shortDescription => '$fromUserName → $toUserName'; + + String get status => isCompleted ? 'Payé' : 'En attente'; + + // Durée depuis la completion (si applicable) + Duration? get timeSinceCompletion { + if (completedAt == null) return null; + return DateTime.now().difference(completedAt!); + } + + // Formatage de la date de completion + String get formattedCompletedAt { + if (completedAt == null) return 'Non payé'; + return 'Payé le ${completedAt!.day}/${completedAt!.month}/${completedAt!.year}'; + } + + // Méthodes de validation + bool get isValid => + fromUserId.isNotEmpty && + toUserId.isNotEmpty && + fromUserId != toUserId && + amount > 0; + + bool get isSelfSettlement => fromUserId == toUserId; + + @override + List get props => [fromUserId, toUserId, amount, isCompleted]; + + @override + String toString() { + return 'Settlement(${shortDescription}: ${formattedAmount}, status: $status)'; + } +} \ No newline at end of file diff --git a/lib/data/models/trip.dart b/lib/models/trip.dart similarity index 100% rename from lib/data/models/trip.dart rename to lib/models/trip.dart diff --git a/lib/data/models/user.dart b/lib/models/user.dart similarity index 100% rename from lib/data/models/user.dart rename to lib/models/user.dart diff --git a/lib/models/user_balance.dart b/lib/models/user_balance.dart new file mode 100644 index 0000000..4a23260 --- /dev/null +++ b/lib/models/user_balance.dart @@ -0,0 +1,98 @@ +import 'package:equatable/equatable.dart'; + +class UserBalance extends Equatable { + final String userId; + final String userName; + final double totalPaid; // Total payé par cet utilisateur + final double totalOwed; // Total dû par cet utilisateur + final double balance; // Différence (positif = à recevoir, négatif = à payer) + + const UserBalance({ + required this.userId, + required this.userName, + required this.totalPaid, + required this.totalOwed, + required this.balance, + }); + + // Constructeur factory pour créer depuis une Map + factory UserBalance.fromMap(Map map) { + return UserBalance( + userId: map['userId'] ?? '', + userName: map['userName'] ?? '', + totalPaid: (map['totalPaid'] as num?)?.toDouble() ?? 0.0, + totalOwed: (map['totalOwed'] as num?)?.toDouble() ?? 0.0, + balance: (map['balance'] as num?)?.toDouble() ?? 0.0, + ); + } + + // Convertir en Map pour la sérialisation + Map toMap() { + return { + 'userId': userId, + 'userName': userName, + 'totalPaid': totalPaid, + 'totalOwed': totalOwed, + 'balance': balance, + }; + } + + // Méthode copyWith pour créer une copie modifiée + UserBalance copyWith({ + String? userId, + String? userName, + double? totalPaid, + double? totalOwed, + double? balance, + }) { + return UserBalance( + userId: userId ?? this.userId, + userName: userName ?? this.userName, + totalPaid: totalPaid ?? this.totalPaid, + totalOwed: totalOwed ?? this.totalOwed, + balance: balance ?? this.balance, + ); + } + + // Constructeur factory pour recalculer automatiquement la balance + factory UserBalance.calculated({ + required String userId, + required String userName, + required double totalPaid, + required double totalOwed, + }) { + return UserBalance( + userId: userId, + userName: userName, + totalPaid: totalPaid, + totalOwed: totalOwed, + balance: totalPaid - totalOwed, + ); + } + + // Getters pour la logique métier + bool get shouldReceive => balance > 0; + bool get shouldPay => balance < 0; + bool get isBalanced => balance.abs() < 0.01; // Tolérance pour les arrondis + + // Montants absolus pour l'affichage + double get absoluteBalance => balance.abs(); + String get balanceStatus { + if (isBalanced) return 'Équilibré'; + if (shouldReceive) return 'À recevoir'; + return 'À payer'; + } + + // Formatage pour l'affichage + String get formattedBalance => '${absoluteBalance.toStringAsFixed(2)} €'; + String get formattedTotalPaid => '${totalPaid.toStringAsFixed(2)} €'; + String get formattedTotalOwed => '${totalOwed.toStringAsFixed(2)} €'; + + @override + List get props => [userId, userName, balance]; + + @override + String toString() { + return 'UserBalance(userId: $userId, userName: $userName, balance: ${balance.toStringAsFixed(2)}€)'; + } +} \ No newline at end of file diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 99d9746..3535370 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -58,7 +58,7 @@ class _HomePageState extends State { page = const GroupContent(); break; case 4: - page = const CountContent(); + page = const AccountContent(); break; default: page = const HomeContent(); diff --git a/lib/repositories/account_repository.dart b/lib/repositories/account_repository.dart index ee9b29f..c7f4c74 100644 --- a/lib/repositories/account_repository.dart +++ b/lib/repositories/account_repository.dart @@ -1,7 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:travel_mate/services/error_service.dart'; -import '../data/models/group_member.dart'; -import '../data/models/account.dart'; +import '../models/group_member.dart'; +import '../models/account.dart'; class AccountRepository { final FirebaseFirestore _firestore = FirebaseFirestore.instance; diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart index fe27641..bab8ccd 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth_repository.dart @@ -1,6 +1,6 @@ import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:cloud_firestore/cloud_firestore.dart'; -import '../data/models/user.dart'; +import '../models/user.dart'; import '../services/auth_service.dart'; class AuthRepository { diff --git a/lib/repositories/balance_repository.dart b/lib/repositories/balance_repository.dart new file mode 100644 index 0000000..db8e941 --- /dev/null +++ b/lib/repositories/balance_repository.dart @@ -0,0 +1,146 @@ +import '../models/group_balance.dart'; +import '../models/expense.dart'; +import '../services/error_service.dart'; +import 'expense_repository.dart'; +import '../models/user_balance.dart'; +import '../models/settlement.dart'; + +class BalanceRepository { + final ExpenseRepository _expenseRepository; + final ErrorService _errorService; + + BalanceRepository({ + required ExpenseRepository expenseRepository, + ErrorService? errorService, + }) : _expenseRepository = expenseRepository, + _errorService = errorService ?? ErrorService(); + + // Calculer la balance d'un groupe + Future calculateGroupBalance(String groupId) async { + try { + // Pour l'instant, on utilise une liste statique des dépenses + // En production, vous récupéreriez depuis le stream + final expenses = await _expenseRepository + .getExpensesStream(groupId) + .first; + + final userBalances = _calculateUserBalances(expenses); + final settlements = _calculateOptimalSettlements(userBalances); + final totalExpenses = expenses + .where((e) => !e.isArchived) + .fold(0.0, (sum, e) => sum + e.amountInEur); + + return GroupBalance( + groupId: groupId, + userBalances: userBalances, + settlements: settlements, + totalExpenses: totalExpenses, + calculatedAt: DateTime.now(), + ); + } catch (e) { + _errorService.logError('BalanceRepository', 'Erreur calcul balance: $e'); + rethrow; + } + } + + // Calculer les balances individuelles + List _calculateUserBalances(List expenses) { + final Map> userBalanceMap = {}; + + // Initialiser les utilisateurs + for (final expense in expenses) { + if (expense.isArchived) continue; + + // Ajouter le payeur + userBalanceMap.putIfAbsent(expense.paidById, () => { + 'name': expense.paidByName, + 'paid': 0.0, + 'owed': 0.0, + }); + + // Ajouter les participants + for (final split in expense.splits) { + userBalanceMap.putIfAbsent(split.userId, () => { + 'name': split.userName, + 'paid': 0.0, + 'owed': 0.0, + }); + } + } + + // Calculer les montants + for (final expense in expenses) { + if (expense.isArchived) continue; + + // Ajouter au montant payé + userBalanceMap[expense.paidById]!['paid'] += expense.amountInEur; + + // Ajouter aux montants dus + for (final split in expense.splits) { + userBalanceMap[split.userId]!['owed'] += split.amount; + } + } + + // Convertir en liste de UserBalance + return userBalanceMap.entries.map((entry) { + final userId = entry.key; + final data = entry.value; + final paid = data['paid'] as double; + final owed = data['owed'] as double; + + return UserBalance( + userId: userId, + userName: data['name'] as String, + totalPaid: paid, + totalOwed: owed, + balance: paid - owed, + ); + }).toList(); + } + + // Algorithme d'optimisation des règlements + List _calculateOptimalSettlements(List balances) { + final settlements = []; + + // Séparer les créditeurs et débiteurs + final creditors = balances.where((b) => b.shouldReceive).toList(); + final debtors = balances.where((b) => b.shouldPay).toList(); + + // Trier par montant (plus gros montants en premier) + creditors.sort((a, b) => b.balance.compareTo(a.balance)); + debtors.sort((a, b) => a.balance.compareTo(b.balance)); + + // Créer des copies mutables des montants + final creditorsRemaining = Map.fromEntries( + creditors.map((c) => MapEntry(c.userId, c.balance)) + ); + final debtorsRemaining = Map.fromEntries( + debtors.map((d) => MapEntry(d.userId, -d.balance)) + ); + + // Algorithme glouton pour minimiser le nombre de transactions + for (final creditor in creditors) { + for (final debtor in debtors) { + final creditAmount = creditorsRemaining[creditor.userId] ?? 0; + final debtAmount = debtorsRemaining[debtor.userId] ?? 0; + + if (creditAmount <= 0.01 || debtAmount <= 0.01) continue; + + final settlementAmount = [creditAmount, debtAmount].reduce((a, b) => a < b ? a : b); + + settlements.add(Settlement( + fromUserId: debtor.userId, + fromUserName: debtor.userName, + toUserId: creditor.userId, + toUserName: creditor.userName, + amount: settlementAmount, + )); + + creditorsRemaining[creditor.userId] = creditAmount - settlementAmount; + debtorsRemaining[debtor.userId] = debtAmount - settlementAmount; + } + } + + return settlements; + } +} \ No newline at end of file diff --git a/lib/repositories/expense_repository.dart b/lib/repositories/expense_repository.dart new file mode 100644 index 0000000..d148246 --- /dev/null +++ b/lib/repositories/expense_repository.dart @@ -0,0 +1,113 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../models/expense.dart'; +import '../services/error_service.dart'; + +class ExpenseRepository { + final FirebaseFirestore _firestore; + final ErrorService _errorService; + + ExpenseRepository({ + FirebaseFirestore? firestore, + ErrorService? errorService, + }) : _firestore = firestore ?? FirebaseFirestore.instance, + _errorService = errorService ?? ErrorService(); + + CollectionReference get _expensesCollection => _firestore.collection('expenses'); + + // Stream des dépenses d'un groupe + Stream> getExpensesStream(String groupId) { + return _expensesCollection + .where('groupId', isEqualTo: groupId) + .where('isArchived', isEqualTo: false) + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => Expense.fromMap(doc.data() as Map, doc.id)) + .toList(); + }).handleError((error) { + _errorService.logError('ExpenseRepository', 'Erreur stream expenses: $error'); + return []; + }); + } + + // Créer une dépense + Future createExpense(Expense expense) async { + try { + final docRef = await _expensesCollection.add(expense.toMap()); + return docRef.id; + } catch (e) { + _errorService.logError('ExpenseRepository', 'Erreur création expense: $e'); + rethrow; + } + } + + // Mettre à jour une dépense + Future updateExpense(Expense expense) async { + try { + final updateData = expense.toMap(); + updateData['editedAt'] = FieldValue.serverTimestamp(); + updateData['isEdited'] = true; + + await _expensesCollection.doc(expense.id).update(updateData); + } catch (e) { + _errorService.logError('ExpenseRepository', 'Erreur update expense: $e'); + rethrow; + } + } + + // Supprimer une dépense + Future deleteExpense(String expenseId) async { + try { + await _expensesCollection.doc(expenseId).delete(); + } catch (e) { + _errorService.logError('ExpenseRepository', 'Erreur delete expense: $e'); + rethrow; + } + } + + // Marquer un split comme payé + Future markSplitAsPaid(String expenseId, String userId) async { + try { + await _firestore.runTransaction((transaction) async { + final expenseDoc = await transaction.get(_expensesCollection.doc(expenseId)); + + if (!expenseDoc.exists) { + throw Exception('Dépense non trouvée'); + } + + final expense = Expense.fromMap( + expenseDoc.data() as Map, + expenseDoc.id + ); + + final updatedSplits = expense.splits.map((split) { + if (split.userId == userId) { + return split.copyWith(isPaid: true, paidAt: DateTime.now()); + } + return split; + }).toList(); + + transaction.update(expenseDoc.reference, { + 'splits': updatedSplits.map((s) => s.toMap()).toList(), + }); + }); + } catch (e) { + _errorService.logError('ExpenseRepository', 'Erreur mark split paid: $e'); + rethrow; + } + } + + // Archiver une dépense + Future archiveExpense(String expenseId) async { + try { + await _expensesCollection.doc(expenseId).update({ + 'isArchived': true, + 'archivedAt': FieldValue.serverTimestamp(), + }); + } catch (e) { + _errorService.logError('ExpenseRepository', 'Erreur archive expense: $e'); + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/repositories/group_repository.dart b/lib/repositories/group_repository.dart index 3698218..bd02b6f 100644 --- a/lib/repositories/group_repository.dart +++ b/lib/repositories/group_repository.dart @@ -1,7 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:travel_mate/services/error_service.dart'; -import '../data/models/group.dart'; -import '../data/models/group_member.dart'; +import '../models/group.dart'; +import '../models/group_member.dart'; class GroupRepository { final FirebaseFirestore _firestore = FirebaseFirestore.instance; diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart index 832640e..a35dc83 100644 --- a/lib/repositories/message_repository.dart +++ b/lib/repositories/message_repository.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import '../data/models/message.dart'; +import '../models/message.dart'; class MessageRepository { final FirebaseFirestore _firestore; diff --git a/lib/repositories/trip_repository.dart b/lib/repositories/trip_repository.dart index b208dcf..38a4767 100644 --- a/lib/repositories/trip_repository.dart +++ b/lib/repositories/trip_repository.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import '../data/models/trip.dart'; +import '../models/trip.dart'; class TripRepository { final FirebaseFirestore _firestore = FirebaseFirestore.instance; diff --git a/lib/repositories/user_repository.dart b/lib/repositories/user_repository.dart index f1b591f..8caaab8 100644 --- a/lib/repositories/user_repository.dart +++ b/lib/repositories/user_repository.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import '../data/models/user.dart'; +import '../models/user.dart'; import '../services/auth_service.dart'; class UserRepository { diff --git a/lib/services/account_service.dart b/lib/services/account_service.dart index e42265a..3b9eed4 100644 --- a/lib/services/account_service.dart +++ b/lib/services/account_service.dart @@ -1,6 +1,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:travel_mate/services/error_service.dart'; -import '../data/models/account.dart'; +import '../models/account.dart'; class AccountService { final _errorService = ErrorService(); diff --git a/lib/services/balance_service.dart b/lib/services/balance_service.dart new file mode 100644 index 0000000..712a024 --- /dev/null +++ b/lib/services/balance_service.dart @@ -0,0 +1,267 @@ +import '../models/group_balance.dart'; +import '../models/expense.dart'; +import '../models/group_statistics.dart'; +import '../models/user_balance.dart'; +import '../models/settlement.dart'; +import '../repositories/balance_repository.dart'; +import '../repositories/expense_repository.dart'; +import 'error_service.dart'; + +class BalanceService { + final BalanceRepository _balanceRepository; + final ExpenseRepository _expenseRepository; + final ErrorService _errorService; + + BalanceService({ + required BalanceRepository balanceRepository, + required ExpenseRepository expenseRepository, + ErrorService? errorService, + }) : _balanceRepository = balanceRepository, + _expenseRepository = expenseRepository, + _errorService = errorService ?? ErrorService(); + + /// Calculer la balance complète d'un groupe + Future calculateGroupBalance(String groupId) async { + try { + return await _balanceRepository.calculateGroupBalance(groupId); + } catch (e) { + _errorService.logError('BalanceService', 'Erreur calcul balance groupe: $e'); + rethrow; + } + } + + /// Stream de la balance en temps réel + Stream getGroupBalanceStream(String groupId) { + return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async { + try { + final userBalances = calculateUserBalances(expenses); + final settlements = optimizeSettlements(userBalances); + final totalExpenses = expenses + .where((e) => !e.isArchived) + .fold(0.0, (sum, e) => sum + e.amountInEur); + + return GroupBalance( + groupId: groupId, + userBalances: userBalances, + settlements: settlements, + totalExpenses: totalExpenses, + calculatedAt: DateTime.now(), + ); + } catch (e) { + _errorService.logError('BalanceService', 'Erreur stream balance: $e'); + rethrow; + } + }); + } + + /// Calculer les balances individuelles (logique métier) + List calculateUserBalances(List expenses) { + final Map calculators = {}; + + // Initialiser les calculateurs pour chaque utilisateur + for (final expense in expenses) { + if (expense.isArchived) continue; + + // Ajouter le payeur + calculators.putIfAbsent( + expense.paidById, + () => _UserBalanceCalculator(expense.paidById, expense.paidByName), + ); + + // Ajouter les participants + for (final split in expense.splits) { + calculators.putIfAbsent( + split.userId, + () => _UserBalanceCalculator(split.userId, split.userName), + ); + } + } + + // Calculer les montants + for (final expense in expenses) { + if (expense.isArchived) continue; + + // Ajouter au montant payé + calculators[expense.paidById]!.addPaid(expense.amountInEur); + + // Ajouter aux montants dus + for (final split in expense.splits) { + calculators[split.userId]!.addOwed(split.amount); + } + } + + // Convertir en UserBalance + return calculators.values.map((calc) => calc.toUserBalance()).toList(); + } + + /// Optimiser les règlements (algorithme avancé) + List optimizeSettlements(List balances) { + final settlements = []; + + // Filtrer les utilisateurs avec une balance significative (> 0.01€) + final creditors = balances + .where((b) => b.shouldReceive && b.balance > 0.01) + .toList(); + final debtors = balances + .where((b) => b.shouldPay && b.balance < -0.01) + .toList(); + + if (creditors.isEmpty || debtors.isEmpty) { + return settlements; + } + + // Trier par montant absolu décroissant pour optimiser + creditors.sort((a, b) => b.balance.compareTo(a.balance)); + debtors.sort((a, b) => a.balance.compareTo(b.balance)); + + // Utiliser des copies mutables pour les calculs + final creditorsRemaining = Map.fromEntries( + creditors.map((c) => MapEntry(c.userId, c.balance)) + ); + final debtorsRemaining = Map.fromEntries( + debtors.map((d) => MapEntry(d.userId, -d.balance)) + ); + + // Algorithme glouton optimisé + for (final creditor in creditors) { + final creditRemaining = creditorsRemaining[creditor.userId] ?? 0; + if (creditRemaining <= 0.01) continue; + + for (final debtor in debtors) { + final debtRemaining = debtorsRemaining[debtor.userId] ?? 0; + if (debtRemaining <= 0.01) continue; + + final settlementAmount = _calculateOptimalSettlementAmount( + creditRemaining, + debtRemaining, + ); + + if (settlementAmount > 0.01) { + settlements.add(Settlement( + fromUserId: debtor.userId, + fromUserName: debtor.userName, + toUserId: creditor.userId, + toUserName: creditor.userName, + amount: settlementAmount, + )); + + // Mettre à jour les montants restants + creditorsRemaining[creditor.userId] = creditRemaining - settlementAmount; + debtorsRemaining[debtor.userId] = debtRemaining - settlementAmount; + } + } + } + + return _validateSettlements(settlements); + } + + /// Calculer le montant optimal pour un règlement + double _calculateOptimalSettlementAmount(double creditAmount, double debtAmount) { + final amount = [creditAmount, debtAmount].reduce((a, b) => a < b ? a : b); + // Arrondir à 2 décimales + return (amount * 100).round() / 100; + } + + /// Valider les règlements calculés + List _validateSettlements(List settlements) { + // Supprimer les règlements trop petits + final validSettlements = settlements + .where((s) => s.amount > 0.01) + .toList(); + + // Log pour debug en cas de problème + final totalSettlements = validSettlements.fold(0.0, (sum, s) => sum + s.amount); + _errorService.logInfo('BalanceService', + 'Règlements calculés: ${validSettlements.length}, Total: ${totalSettlements.toStringAsFixed(2)}€'); + + return validSettlements; + } + + /// Calculer la dette entre deux utilisateurs spécifiques + double calculateDebtBetweenUsers(String groupId, String userId1, String userId2) { + // Cette méthode pourrait être utile pour des fonctionnalités avancées + // comme "Combien me doit X ?" ou "Combien je dois à Y ?" + return 0.0; // TODO: Implémenter si nécessaire + } + + /// Analyser les tendances de dépenses par catégorie + Map analyzeCategorySpending(List expenses) { + final categoryTotals = {}; + + for (final expense in expenses) { + if (expense.isArchived) continue; + + final categoryName = expense.category.displayName; + categoryTotals[categoryName] = (categoryTotals[categoryName] ?? 0) + expense.amountInEur; + } + + return categoryTotals; + } + + /// Calculer les statistiques du groupe + GroupStatistics calculateGroupStatistics(List expenses) { + if (expenses.isEmpty) { + return GroupStatistics.empty(); + } + + final nonArchivedExpenses = expenses.where((e) => !e.isArchived).toList(); + final totalAmount = nonArchivedExpenses.fold(0.0, (sum, e) => sum + e.amountInEur); + final averageAmount = totalAmount / nonArchivedExpenses.length; + + final categorySpending = analyzeCategorySpending(nonArchivedExpenses); + final topCategory = categorySpending.entries + .reduce((a, b) => a.value > b.value ? a : b) + .key; + + return GroupStatistics( + totalExpenses: totalAmount, + expenseCount: nonArchivedExpenses.length, + averageExpense: averageAmount, + topCategory: topCategory, + categoryBreakdown: categorySpending, + ); + } + + Future markSettlementAsCompleted({ + required String groupId, + required String fromUserId, + required String toUserId, + required double amount, + }) async { + try { + // Ici vous pourriez enregistrer le règlement en base + // ou créer une transaction de règlement + + // Pour l'instant, on pourrait juste recalculer + await Future.delayed(const Duration(milliseconds: 100)); + + _errorService.logSuccess('BalanceService', 'Règlement marqué comme effectué'); + } catch (e) { + _errorService.logError('BalanceService', 'Erreur mark settlement: $e'); + rethrow; + } + } +} + +/// Classe helper pour calculer les balances +class _UserBalanceCalculator { + final String userId; + final String userName; + double _totalPaid = 0.0; + double _totalOwed = 0.0; + + _UserBalanceCalculator(this.userId, this.userName); + + void addPaid(double amount) => _totalPaid += amount; + void addOwed(double amount) => _totalOwed += amount; + + UserBalance toUserBalance() { + return UserBalance( + userId: userId, + userName: userName, + totalPaid: _totalPaid, + totalOwed: _totalOwed, + balance: _totalPaid - _totalOwed, + ); + } +} \ No newline at end of file diff --git a/lib/services/expense_service.dart b/lib/services/expense_service.dart new file mode 100644 index 0000000..c7e1fe2 --- /dev/null +++ b/lib/services/expense_service.dart @@ -0,0 +1,91 @@ +import 'dart:io'; +import '../models/expense.dart'; +import '../repositories/expense_repository.dart'; +import 'error_service.dart'; +import 'storage_service.dart'; // Pour upload des reçus + +class ExpenseService { + final ExpenseRepository _expenseRepository; + final ErrorService _errorService; + final StorageService _storageService; + + ExpenseService({ + required ExpenseRepository expenseRepository, + ErrorService? errorService, + StorageService? storageService, + }) : _expenseRepository = expenseRepository, + _errorService = errorService ?? ErrorService(), + _storageService = storageService ?? StorageService(); + + // Création avec validation et upload d'image + Future createExpenseWithValidation(Expense expense, File? receiptImage) async { + try { + // Validation métier + _validateExpenseData(expense); + + // Upload du reçu si présent + String? receiptUrl; + if (receiptImage != null) { + receiptUrl = await _storageService.uploadReceiptImage( + expense.groupId, + receiptImage, + ); + } + + // Créer l'expense avec l'URL du reçu + final expenseWithReceipt = expense.copyWith(receiptUrl: receiptUrl); + + return await _expenseRepository.createExpense(expenseWithReceipt); + } catch (e) { + _errorService.logError('ExpenseService', 'Erreur création expense: $e'); + rethrow; + } + } + + // Mise à jour avec validation + Future updateExpenseWithValidation(Expense expense, File? newReceiptImage) async { + try { + _validateExpenseData(expense); + + // Gérer le nouvel upload si nécessaire + String? receiptUrl = expense.receiptUrl; + if (newReceiptImage != null) { + receiptUrl = await _storageService.uploadReceiptImage( + expense.groupId, + newReceiptImage, + ); + } + + final expenseWithReceipt = expense.copyWith( + receiptUrl: receiptUrl, + editedAt: DateTime.now(), + isEdited: true, + ); + + await _expenseRepository.updateExpense(expenseWithReceipt); + } catch (e) { + _errorService.logError('ExpenseService', 'Erreur update expense: $e'); + rethrow; + } + } + + // Validation des données + void _validateExpenseData(Expense expense) { + if (expense.description.trim().isEmpty) { + throw Exception('La description est requise'); + } + + if (expense.amount <= 0) { + throw Exception('Le montant doit être positif'); + } + + if (expense.splits.isEmpty) { + throw Exception('Au moins un participant est requis'); + } + + final totalSplits = expense.splits.fold(0.0, (sum, split) => sum + split.amount); + if ((totalSplits - expense.amountInEur).abs() > 0.01) { + throw Exception('La somme des répartitions ne correspond pas au montant total'); + } + } +} \ No newline at end of file diff --git a/lib/services/group_service.dart b/lib/services/group_service.dart index 8190cab..7eaeb72 100644 --- a/lib/services/group_service.dart +++ b/lib/services/group_service.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:travel_mate/data/models/group.dart'; +import 'package:travel_mate/models/group.dart'; import 'package:travel_mate/services/error_service.dart'; class GroupService { diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index 35c107e..21dd0e7 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -1,4 +1,4 @@ -import '../data/models/message.dart'; +import '../models/message.dart'; import '../repositories/message_repository.dart'; import 'error_service.dart'; diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 0000000..d7c6761 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,214 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; +import 'error_service.dart'; + +class StorageService { + final FirebaseStorage _storage; + final ErrorService _errorService; + + StorageService({ + FirebaseStorage? storage, + ErrorService? errorService, + }) : _storage = storage ?? FirebaseStorage.instance, + _errorService = errorService ?? ErrorService(); + + /// Upload d'une image de reçu pour une dépense + Future uploadReceiptImage(String groupId, File imageFile) async { + try { + // Validation du fichier + _validateImageFile(imageFile); + + // Compression de l'image + final compressedImage = await _compressImage(imageFile); + + // Génération du nom de fichier unique + final fileName = _generateReceiptFileName(groupId); + + // Référence vers le storage + final storageRef = _storage.ref().child('receipts/$groupId/$fileName'); + + // Métadonnées pour optimiser le cache et la compression + final metadata = SettableMetadata( + contentType: 'image/jpeg', + customMetadata: { + 'groupId': groupId, + 'uploadedAt': DateTime.now().toIso8601String(), + 'compressed': 'true', + }, + ); + + // Upload du fichier + final uploadTask = storageRef.putData(compressedImage, metadata); + + // Monitoring du progrès (optionnel) + uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) { + final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + _errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%'); + }); + + // Attendre la completion + final snapshot = await uploadTask; + + // Récupérer l'URL de téléchargement + final downloadUrl = await snapshot.ref.getDownloadURL(); + + _errorService.logSuccess('StorageService', 'Image uploadée avec succès: $fileName'); + return downloadUrl; + + } catch (e) { + _errorService.logError('StorageService', 'Erreur upload image: $e'); + rethrow; + } + } + + /// Supprimer une image de reçu + Future deleteReceiptImage(String imageUrl) async { + try { + if (imageUrl.isEmpty) return; + + // Extraire la référence depuis l'URL + final ref = _storage.refFromURL(imageUrl); + await ref.delete(); + + _errorService.logSuccess('StorageService', 'Image supprimée avec succès'); + } catch (e) { + _errorService.logError('StorageService', 'Erreur suppression image: $e'); + // Ne pas rethrow pour éviter de bloquer la suppression de la dépense + } + } + + /// Compresser une image pour optimiser le stockage + Future _compressImage(File imageFile) async { + try { + // Lire l'image + final bytes = await imageFile.readAsBytes(); + img.Image? image = img.decodeImage(bytes); + + if (image == null) { + throw Exception('Impossible de décoder l\'image'); + } + + // Redimensionner si l'image est trop grande + const maxWidth = 1024; + const maxHeight = 1024; + + if (image.width > maxWidth || image.height > maxHeight) { + image = img.copyResize( + image, + width: image.width > image.height ? maxWidth : null, + height: image.height > image.width ? maxHeight : null, + interpolation: img.Interpolation.linear, + ); + } + + // Encoder en JPEG avec compression + final compressedBytes = img.encodeJpg(image, quality: 85); + + _errorService.logInfo('StorageService', + 'Image compressée: ${bytes.length} → ${compressedBytes.length} bytes'); + + return Uint8List.fromList(compressedBytes); + } catch (e) { + _errorService.logError('StorageService', 'Erreur compression image: $e'); + // Fallback: retourner l'image originale si la compression échoue + return await imageFile.readAsBytes(); + } + } + + /// Valider le fichier image + void _validateImageFile(File imageFile) { + // Vérifier que le fichier existe + if (!imageFile.existsSync()) { + throw Exception('Le fichier image n\'existe pas'); + } + + // Vérifier la taille du fichier (max 10MB) + const maxSizeBytes = 10 * 1024 * 1024; // 10MB + final fileSize = imageFile.lengthSync(); + if (fileSize > maxSizeBytes) { + throw Exception('La taille du fichier dépasse 10MB'); + } + + // Vérifier l'extension + final extension = path.extension(imageFile.path).toLowerCase(); + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp']; + if (!allowedExtensions.contains(extension)) { + throw Exception('Format d\'image non supporté. Utilisez JPG, PNG ou WebP'); + } + } + + /// Générer un nom de fichier unique pour un reçu + String _generateReceiptFileName(String groupId) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = DateTime.now().microsecond; + return 'receipt_${groupId}_${timestamp}_$random.jpg'; + } + + /// Upload multiple d'images (pour futures fonctionnalités) + Future> uploadMultipleImages( + String groupId, + List imageFiles, + ) async { + final uploadTasks = imageFiles.map((file) => uploadReceiptImage(groupId, file)); + return await Future.wait(uploadTasks); + } + + /// Récupérer les métadonnées d'une image + Future getImageMetadata(String imageUrl) async { + try { + final ref = _storage.refFromURL(imageUrl); + return await ref.getMetadata(); + } catch (e) { + _errorService.logError('StorageService', 'Erreur récupération metadata: $e'); + return null; + } + } + + /// Nettoyer les images orphelines d'un groupe + Future cleanupGroupImages(String groupId) async { + try { + final groupRef = _storage.ref().child('receipts/$groupId'); + final listResult = await groupRef.listAll(); + + for (final ref in listResult.items) { + // Vérifier l'âge du fichier + final metadata = await ref.getMetadata(); + final uploadDate = metadata.timeCreated; + + if (uploadDate != null) { + final daysSinceUpload = DateTime.now().difference(uploadDate).inDays; + + // Supprimer les fichiers de plus de 30 jours sans dépense associée + if (daysSinceUpload > 30) { + await ref.delete(); + _errorService.logInfo('StorageService', 'Image orpheline supprimée: ${ref.name}'); + } + } + } + } catch (e) { + _errorService.logError('StorageService', 'Erreur nettoyage images: $e'); + } + } + + /// Calculer la taille totale utilisée par un groupe + Future getGroupStorageSize(String groupId) async { + try { + final groupRef = _storage.ref().child('receipts/$groupId'); + final listResult = await groupRef.listAll(); + + int totalSize = 0; + for (final ref in listResult.items) { + final metadata = await ref.getMetadata(); + totalSize += metadata.size ?? 0; + } + + return totalSize; + } catch (e) { + _errorService.logError('StorageService', 'Erreur calcul taille storage: $e'); + return 0; + } + } +} \ No newline at end of file diff --git a/lib/services/trip_service.dart b/lib/services/trip_service.dart index 2d90461..6597ebe 100644 --- a/lib/services/trip_service.dart +++ b/lib/services/trip_service.dart @@ -1,6 +1,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:travel_mate/services/error_service.dart'; -import '../data/models/trip.dart'; +import '../models/trip.dart'; class TripService { final _errorService = ErrorService();