feat: Add User and UserBalance models with serialization methods
feat: Implement BalanceRepository for group balance calculations feat: Create ExpenseRepository for managing expenses feat: Add services for handling expenses and storage operations fix: Update import paths for models in repositories and services refactor: Rename CountContent to AccountContent in HomePage chore: Add StorageService for image upload and management
This commit is contained in:
@@ -4,7 +4,7 @@ import 'package:travel_mate/services/error_service.dart';
|
|||||||
import 'account_event.dart';
|
import 'account_event.dart';
|
||||||
import 'account_state.dart';
|
import 'account_state.dart';
|
||||||
import '../../repositories/account_repository.dart';
|
import '../../repositories/account_repository.dart';
|
||||||
import '../../data/models/account.dart';
|
import '../../models/account.dart';
|
||||||
|
|
||||||
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
||||||
final AccountRepository _repository;
|
final AccountRepository _repository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/account.dart';
|
import '../../models/account.dart';
|
||||||
import '../../data/models/group_member.dart';
|
import '../../models/group_member.dart';
|
||||||
|
|
||||||
abstract class AccountEvent extends Equatable {
|
abstract class AccountEvent extends Equatable {
|
||||||
const AccountEvent();
|
const AccountEvent();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/account.dart';
|
import '../../models/account.dart';
|
||||||
|
|
||||||
abstract class AccountState extends Equatable {
|
abstract class AccountState extends Equatable {
|
||||||
const AccountState();
|
const AccountState();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/user.dart';
|
import '../../models/user.dart';
|
||||||
|
|
||||||
abstract class AuthState extends Equatable {
|
abstract class AuthState extends Equatable {
|
||||||
const AuthState();
|
const AuthState();
|
||||||
|
|||||||
81
lib/blocs/balance/balance_bloc.dart
Normal file
81
lib/blocs/balance/balance_bloc.dart
Normal file
@@ -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<BalanceEvent, BalanceState> {
|
||||||
|
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<LoadGroupBalance>(_onLoadGroupBalance);
|
||||||
|
on<RefreshBalance>(_onRefreshBalance);
|
||||||
|
on<MarkSettlementAsCompleted>(_onMarkSettlementAsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadGroupBalance(
|
||||||
|
LoadGroupBalance event,
|
||||||
|
Emitter<BalanceState> 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<void> _onRefreshBalance(
|
||||||
|
RefreshBalance event,
|
||||||
|
Emitter<BalanceState> 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<void> _onMarkSettlementAsCompleted(
|
||||||
|
MarkSettlementAsCompleted event,
|
||||||
|
Emitter<BalanceState> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/blocs/balance/balance_event.dart
Normal file
43
lib/blocs/balance/balance_event.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
abstract class BalanceEvent extends Equatable {
|
||||||
|
const BalanceEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadGroupBalance extends BalanceEvent {
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
const LoadGroupBalance(this.groupId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [groupId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshBalance extends BalanceEvent {
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
const RefreshBalance(this.groupId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> 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<Object?> get props => [groupId, fromUserId, toUserId, amount];
|
||||||
|
}
|
||||||
40
lib/blocs/balance/balance_state.dart
Normal file
40
lib/blocs/balance/balance_state.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../models/group_balance.dart';
|
||||||
|
|
||||||
|
abstract class BalanceState extends Equatable {
|
||||||
|
const BalanceState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BalanceInitial extends BalanceState {}
|
||||||
|
|
||||||
|
class BalanceLoading extends BalanceState {}
|
||||||
|
|
||||||
|
class BalanceLoaded extends BalanceState {
|
||||||
|
final GroupBalance groupBalance;
|
||||||
|
|
||||||
|
const BalanceLoaded(this.groupBalance);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [groupBalance];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BalanceOperationSuccess extends BalanceState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const BalanceOperationSuccess(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BalanceError extends BalanceState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const BalanceError(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
136
lib/blocs/expense/expense_bloc.dart
Normal file
136
lib/blocs/expense/expense_bloc.dart
Normal file
@@ -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<ExpenseEvent, ExpenseState> {
|
||||||
|
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<LoadExpensesByGroup>(_onLoadExpensesByGroup);
|
||||||
|
on<ExpensesUpdated>(_onExpensesUpdated);
|
||||||
|
on<CreateExpense>(_onCreateExpense);
|
||||||
|
on<UpdateExpense>(_onUpdateExpense);
|
||||||
|
on<DeleteExpense>(_onDeleteExpense);
|
||||||
|
on<MarkSplitAsPaid>(_onMarkSplitAsPaid);
|
||||||
|
on<ArchiveExpense>(_onArchiveExpense);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadExpensesByGroup(
|
||||||
|
LoadExpensesByGroup event,
|
||||||
|
Emitter<ExpenseState> 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<void> _onExpensesUpdated(
|
||||||
|
ExpensesUpdated event,
|
||||||
|
Emitter<ExpenseState> emit,
|
||||||
|
) async {
|
||||||
|
if (event.error != null) {
|
||||||
|
emit(ExpenseError(event.error!));
|
||||||
|
} else {
|
||||||
|
emit(ExpensesLoaded(expenses: event.expenses));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCreateExpense(
|
||||||
|
CreateExpense event,
|
||||||
|
Emitter<ExpenseState> 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<void> _onUpdateExpense(
|
||||||
|
UpdateExpense event,
|
||||||
|
Emitter<ExpenseState> 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<void> _onDeleteExpense(
|
||||||
|
DeleteExpense event,
|
||||||
|
Emitter<ExpenseState> 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<void> _onMarkSplitAsPaid(
|
||||||
|
MarkSplitAsPaid event,
|
||||||
|
Emitter<ExpenseState> 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<void> _onArchiveExpense(
|
||||||
|
ArchiveExpense event,
|
||||||
|
Emitter<ExpenseState> 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<void> close() {
|
||||||
|
_expensesSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/blocs/expense/expense_event.dart
Normal file
87
lib/blocs/expense/expense_event.dart
Normal file
@@ -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<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadExpensesByGroup extends ExpenseEvent {
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
const LoadExpensesByGroup(this.groupId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [groupId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateExpense extends ExpenseEvent {
|
||||||
|
final Expense expense;
|
||||||
|
final File? receiptImage;
|
||||||
|
|
||||||
|
const CreateExpense({
|
||||||
|
required this.expense,
|
||||||
|
this.receiptImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expense, receiptImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateExpense extends ExpenseEvent {
|
||||||
|
final Expense expense;
|
||||||
|
final File? newReceiptImage;
|
||||||
|
|
||||||
|
const UpdateExpense({
|
||||||
|
required this.expense,
|
||||||
|
this.newReceiptImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expense, newReceiptImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteExpense extends ExpenseEvent {
|
||||||
|
final String expenseId;
|
||||||
|
|
||||||
|
const DeleteExpense(this.expenseId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expenseId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkSplitAsPaid extends ExpenseEvent {
|
||||||
|
final String expenseId;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
const MarkSplitAsPaid({
|
||||||
|
required this.expenseId,
|
||||||
|
required this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expenseId, userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArchiveExpense extends ExpenseEvent {
|
||||||
|
final String expenseId;
|
||||||
|
|
||||||
|
const ArchiveExpense(this.expenseId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expenseId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événement privé pour les mises à jour du stream
|
||||||
|
class ExpensesUpdated extends ExpenseEvent {
|
||||||
|
final List<Expense> expenses;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const ExpensesUpdated(this.expenses, {this.error});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expenses, error];
|
||||||
|
}
|
||||||
44
lib/blocs/expense/expense_state.dart
Normal file
44
lib/blocs/expense/expense_state.dart
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../models/expense.dart';
|
||||||
|
|
||||||
|
abstract class ExpenseState extends Equatable {
|
||||||
|
const ExpenseState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseInitial extends ExpenseState {}
|
||||||
|
|
||||||
|
class ExpenseLoading extends ExpenseState {}
|
||||||
|
|
||||||
|
class ExpensesLoaded extends ExpenseState {
|
||||||
|
final List<Expense> expenses;
|
||||||
|
final Map<String, double> exchangeRates;
|
||||||
|
|
||||||
|
const ExpensesLoaded({
|
||||||
|
required this.expenses,
|
||||||
|
this.exchangeRates = const {'EUR': 1.0, 'USD': 0.85, 'GBP': 1.15},
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [expenses, exchangeRates];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseOperationSuccess extends ExpenseState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ExpenseOperationSuccess(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseError extends ExpenseState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ExpenseError(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:travel_mate/services/error_service.dart';
|
|||||||
import 'group_event.dart';
|
import 'group_event.dart';
|
||||||
import 'group_state.dart';
|
import 'group_state.dart';
|
||||||
import '../../repositories/group_repository.dart';
|
import '../../repositories/group_repository.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
|
|
||||||
class GroupBloc extends Bloc<GroupEvent, GroupState> {
|
class GroupBloc extends Bloc<GroupEvent, GroupState> {
|
||||||
final GroupRepository _repository;
|
final GroupRepository _repository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import '../../data/models/group_member.dart';
|
import '../../models/group_member.dart';
|
||||||
|
|
||||||
abstract class GroupEvent extends Equatable {
|
abstract class GroupEvent extends Equatable {
|
||||||
const GroupEvent();
|
const GroupEvent();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
|
|
||||||
abstract class GroupState extends Equatable {
|
abstract class GroupState extends Equatable {
|
||||||
const GroupState();
|
const GroupState();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../services/message_service.dart';
|
import '../../services/message_service.dart';
|
||||||
import '../../repositories/message_repository.dart';
|
import '../../repositories/message_repository.dart';
|
||||||
import 'message_event.dart';
|
import 'message_event.dart';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
|
||||||
abstract class MessageState extends Equatable {
|
abstract class MessageState extends Equatable {
|
||||||
const MessageState();
|
const MessageState();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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_event.dart';
|
||||||
import 'trip_state.dart';
|
import 'trip_state.dart';
|
||||||
import '../../repositories/trip_repository.dart';
|
import '../../repositories/trip_repository.dart';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../models/trip.dart';
|
||||||
|
|
||||||
abstract class TripEvent extends Equatable {
|
abstract class TripEvent extends Equatable {
|
||||||
const TripEvent();
|
const TripEvent();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../models/trip.dart';
|
||||||
|
|
||||||
abstract class TripState extends Equatable {
|
abstract class TripState extends Equatable {
|
||||||
const TripState();
|
const TripState();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:travel_mate/blocs/user/user_bloc.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_bloc.dart';
|
||||||
import '../../blocs/account/account_event.dart';
|
import '../../blocs/account/account_event.dart';
|
||||||
import '../../blocs/account/account_state.dart';
|
import '../../blocs/account/account_state.dart';
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../blocs/account/account_bloc.dart';
|
import 'package:travel_mate/models/expense_split.dart';
|
||||||
import '../../blocs/account/account_event.dart';
|
import '../../blocs/expense/expense_bloc.dart';
|
||||||
import '../../blocs/account/account_state.dart';
|
import '../../blocs/expense/expense_event.dart';
|
||||||
|
import '../../blocs/expense/expense_state.dart';
|
||||||
import '../../blocs/user/user_state.dart' as user_state;
|
import '../../blocs/user/user_state.dart' as user_state;
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import '../../data/models/expense.dart';
|
import '../../models/expense.dart';
|
||||||
|
|
||||||
class AddExpenseDialog extends StatefulWidget {
|
class AddExpenseDialog extends StatefulWidget {
|
||||||
final Group group;
|
final Group group;
|
||||||
@@ -148,8 +149,8 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Convertir en EUR
|
// Convertir en EUR
|
||||||
final amountInEur = context.read<CountBloc>().state is ExpensesLoaded
|
final amountInEur = context.read<ExpenseBloc>().state is ExpensesLoaded
|
||||||
? (context.read<CountBloc>().state as ExpensesLoaded)
|
? (context.read<ExpenseBloc>().state as ExpensesLoaded)
|
||||||
.exchangeRates[_selectedCurrency]! * amount
|
.exchangeRates[_selectedCurrency]! * amount
|
||||||
: amount;
|
: amount;
|
||||||
|
|
||||||
@@ -168,15 +169,16 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
|||||||
splits: selectedSplits,
|
splits: selectedSplits,
|
||||||
date: _selectedDate,
|
date: _selectedDate,
|
||||||
receiptUrl: widget.expenseToEdit?.receiptUrl,
|
receiptUrl: widget.expenseToEdit?.receiptUrl,
|
||||||
|
createdAt: widget.expenseToEdit?.createdAt ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.expenseToEdit == null) {
|
if (widget.expenseToEdit == null) {
|
||||||
context.read<CountBloc>().add(CreateExpense(
|
context.read<ExpenseBloc>().add(CreateExpense(
|
||||||
expense: expense,
|
expense: expense,
|
||||||
receiptImage: _receiptImage,
|
receiptImage: _receiptImage,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
context.read<CountBloc>().add(UpdateExpense(
|
context.read<ExpenseBloc>().add(UpdateExpense(
|
||||||
expense: expense,
|
expense: expense,
|
||||||
newReceiptImage: _receiptImage,
|
newReceiptImage: _receiptImage,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../data/models/balance.dart';
|
import '../../models/user_balance.dart';
|
||||||
|
|
||||||
class BalancesTab extends StatelessWidget {
|
class BalancesTab extends StatelessWidget {
|
||||||
final List<Balance> balances;
|
final List<UserBalance> balances;
|
||||||
|
|
||||||
const BalancesTab({
|
const BalancesTab({
|
||||||
super.key,
|
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;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
Color balanceColor;
|
Color balanceColor;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../blocs/account/account_bloc.dart';
|
import 'package:travel_mate/models/expense_split.dart';
|
||||||
import '../../blocs/account/account_event.dart';
|
import '../../blocs/expense/expense_bloc.dart';
|
||||||
|
import '../../blocs/expense/expense_event.dart';
|
||||||
import '../../blocs/user/user_bloc.dart';
|
import '../../blocs/user/user_bloc.dart';
|
||||||
import '../../blocs/user/user_state.dart' as user_state;
|
import '../../blocs/user/user_state.dart' as user_state;
|
||||||
import '../../data/models/expense.dart';
|
import '../../models/expense.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import 'add_expense_dialog.dart';
|
import 'add_expense_dialog.dart';
|
||||||
|
|
||||||
class ExpenseDetailDialog extends StatelessWidget {
|
class ExpenseDetailDialog extends StatelessWidget {
|
||||||
@@ -267,8 +268,7 @@ class ExpenseDetailDialog extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check_circle, color: Colors.green),
|
icon: const Icon(Icons.check_circle, color: Colors.green),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<CountBloc>().add(MarkSplitAsPaid(
|
context.read<ExpenseBloc>().add(MarkSplitAsPaid(
|
||||||
groupId: expense.groupId,
|
|
||||||
expenseId: expense.id,
|
expenseId: expense.id,
|
||||||
userId: split.userId,
|
userId: split.userId,
|
||||||
));
|
));
|
||||||
@@ -287,7 +287,7 @@ class ExpenseDetailDialog extends StatelessWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => BlocProvider.value(
|
builder: (dialogContext) => BlocProvider.value(
|
||||||
value: context.read<CountBloc>(),
|
value: context.read<ExpenseBloc>(),
|
||||||
child: AddExpenseDialog(
|
child: AddExpenseDialog(
|
||||||
group: group,
|
group: group,
|
||||||
currentUser: currentUser,
|
currentUser: currentUser,
|
||||||
@@ -310,9 +310,8 @@ class ExpenseDetailDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<CountBloc>().add(DeleteExpense(
|
context.read<ExpenseBloc>().add(DeleteExpense(
|
||||||
groupId: expense.groupId,
|
expense.id,
|
||||||
expenseId: expense.id,
|
|
||||||
));
|
));
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -338,9 +337,8 @@ class ExpenseDetailDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<CountBloc>().add(ArchiveExpense(
|
context.read<ExpenseBloc>().add(ArchiveExpense(
|
||||||
groupId: expense.groupId,
|
expense.id,
|
||||||
expenseId: expense.id,
|
|
||||||
));
|
));
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../data/models/expense.dart';
|
import '../../models/expense.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import 'expense_detail_dialog.dart';
|
import 'expense_detail_dialog.dart';
|
||||||
|
|
||||||
class ExpensesTab extends StatelessWidget {
|
class ExpensesTab extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../data/models/balance.dart';
|
import '../../models/settlement.dart';
|
||||||
|
|
||||||
class SettlementsTab extends StatelessWidget {
|
class SettlementsTab extends StatelessWidget {
|
||||||
final List<Settlement> settlements;
|
final List<Settlement> settlements;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import '../../blocs/user/user_state.dart' as user_state;
|
|||||||
import '../../blocs/message/message_bloc.dart';
|
import '../../blocs/message/message_bloc.dart';
|
||||||
import '../../blocs/message/message_event.dart';
|
import '../../blocs/message/message_event.dart';
|
||||||
import '../../blocs/message/message_state.dart';
|
import '../../blocs/message/message_state.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
|
||||||
class ChatGroupContent extends StatefulWidget {
|
class ChatGroupContent extends StatefulWidget {
|
||||||
final Group group;
|
final Group group;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../../blocs/user/user_state.dart' as user_state;
|
|||||||
import '../../blocs/group/group_bloc.dart';
|
import '../../blocs/group/group_bloc.dart';
|
||||||
import '../../blocs/group/group_state.dart';
|
import '../../blocs/group/group_state.dart';
|
||||||
import '../../blocs/group/group_event.dart';
|
import '../../blocs/group/group_event.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
|
|
||||||
class GroupContent extends StatefulWidget {
|
class GroupContent extends StatefulWidget {
|
||||||
const GroupContent({super.key});
|
const GroupContent({super.key});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 'package:travel_mate/services/error_service.dart';
|
||||||
import '../../blocs/user/user_bloc.dart';
|
import '../../blocs/user/user_bloc.dart';
|
||||||
import '../../blocs/user/user_state.dart' as user_state;
|
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/trip/trip_state.dart';
|
||||||
import '../../blocs/group/group_bloc.dart';
|
import '../../blocs/group/group_bloc.dart';
|
||||||
import '../../blocs/group/group_event.dart';
|
import '../../blocs/group/group_event.dart';
|
||||||
import '../../data/models/group.dart';
|
import '../../models/group.dart';
|
||||||
import '../../data/models/group_member.dart';
|
import '../../models/group_member.dart';
|
||||||
import '../../services/user_service.dart';
|
import '../../services/user_service.dart';
|
||||||
import '../../repositories/group_repository.dart';
|
import '../../repositories/group_repository.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../../blocs/user/user_state.dart';
|
|||||||
import '../../blocs/trip/trip_bloc.dart';
|
import '../../blocs/trip/trip_bloc.dart';
|
||||||
import '../../blocs/trip/trip_state.dart';
|
import '../../blocs/trip/trip_state.dart';
|
||||||
import '../../blocs/trip/trip_event.dart';
|
import '../../blocs/trip/trip_event.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../models/trip.dart';
|
||||||
|
|
||||||
class HomeContent extends StatefulWidget {
|
class HomeContent extends StatefulWidget {
|
||||||
const HomeContent({super.key});
|
const HomeContent({super.key});
|
||||||
|
|||||||
@@ -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_bloc.dart';
|
||||||
import 'package:travel_mate/blocs/trip/trip_event.dart';
|
import 'package:travel_mate/blocs/trip/trip_event.dart';
|
||||||
import 'package:travel_mate/components/home/create_trip_content.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 {
|
class ShowTripDetailsContent extends StatefulWidget {
|
||||||
final Trip trip;
|
final Trip trip;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<String, dynamic> toMap() {
|
|
||||||
return {
|
|
||||||
'userId': userId,
|
|
||||||
'userName': userName,
|
|
||||||
'amount': amount,
|
|
||||||
'isPaid': isPaid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory ExpenseSplit.fromMap(Map<String, dynamic> 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<ExpenseSplit> 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<String, dynamic> 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<String, dynamic>;
|
|
||||||
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<dynamic>?)
|
|
||||||
?.map((s) => ExpenseSplit.fromMap(s as Map<String, dynamic>))
|
|
||||||
.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<ExpenseSplit>? 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:firebase_core/firebase_core.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/blocs/message/message_bloc.dart';
|
||||||
|
import 'package:travel_mate/services/balance_service.dart';
|
||||||
import 'package:travel_mate/services/error_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_bloc.dart';
|
||||||
import 'blocs/auth/auth_event.dart';
|
import 'blocs/auth/auth_event.dart';
|
||||||
import 'blocs/theme/theme_bloc.dart';
|
import 'blocs/theme/theme_bloc.dart';
|
||||||
@@ -19,6 +23,8 @@ import 'repositories/user_repository.dart';
|
|||||||
import 'repositories/group_repository.dart';
|
import 'repositories/group_repository.dart';
|
||||||
import 'repositories/message_repository.dart';
|
import 'repositories/message_repository.dart';
|
||||||
import 'repositories/account_repository.dart';
|
import 'repositories/account_repository.dart';
|
||||||
|
import 'repositories/expense_repository.dart';
|
||||||
|
import 'repositories/balance_repository.dart';
|
||||||
import 'pages/login.dart';
|
import 'pages/login.dart';
|
||||||
import 'pages/home.dart';
|
import 'pages/home.dart';
|
||||||
import 'pages/signup.dart';
|
import 'pages/signup.dart';
|
||||||
@@ -54,6 +60,17 @@ class MyApp extends StatelessWidget {
|
|||||||
RepositoryProvider<MessageRepository>(
|
RepositoryProvider<MessageRepository>(
|
||||||
create: (context) => MessageRepository(),
|
create: (context) => MessageRepository(),
|
||||||
),
|
),
|
||||||
|
RepositoryProvider<AccountRepository>(
|
||||||
|
create: (context) => AccountRepository(),
|
||||||
|
),
|
||||||
|
RepositoryProvider<ExpenseRepository>(
|
||||||
|
create: (context) => ExpenseRepository(),
|
||||||
|
),
|
||||||
|
RepositoryProvider<BalanceRepository>(
|
||||||
|
create: (context) => BalanceRepository(
|
||||||
|
expenseRepository: context.read<ExpenseRepository>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
],
|
],
|
||||||
child: MultiBlocProvider(
|
child: MultiBlocProvider(
|
||||||
@@ -77,6 +94,26 @@ class MyApp extends StatelessWidget {
|
|||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => MessageBloc(),
|
create: (context) => MessageBloc(),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => AccountBloc(
|
||||||
|
context.read<AccountRepository>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Nouveaux blocs
|
||||||
|
BlocProvider<ExpenseBloc>(
|
||||||
|
create: (context) => ExpenseBloc(
|
||||||
|
expenseRepository: context.read<ExpenseRepository>(),
|
||||||
|
expenseService: context.read<ExpenseService>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlocProvider<BalanceBloc>(
|
||||||
|
create: (context) => BalanceBloc(
|
||||||
|
balanceRepository: context.read<BalanceRepository>(),
|
||||||
|
balanceService: context.read<BalanceService>(),
|
||||||
|
expenseRepository: context.read<ExpenseRepository>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
],
|
],
|
||||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||||
|
|||||||
205
lib/models/expense.dart
Normal file
205
lib/models/expense.dart
Normal file
@@ -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<ExpenseSplit> 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<String, dynamic> 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<String, dynamic> 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<ExpenseSplit>? 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<ExpenseSplit>? 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<ExpenseSplit> newSplits) {
|
||||||
|
return copyWith(
|
||||||
|
splits: newSplits,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
58
lib/models/expense_split.dart
Normal file
58
lib/models/expense_split.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [userId, userName, amount, isPaid];
|
||||||
|
}
|
||||||
93
lib/models/group_balance.dart
Normal file
93
lib/models/group_balance.dart
Normal file
@@ -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<UserBalance> userBalances;
|
||||||
|
final List<Settlement> 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<String, dynamic> map) {
|
||||||
|
return GroupBalance(
|
||||||
|
groupId: map['groupId'] ?? '',
|
||||||
|
userBalances: (map['userBalances'] as List?)
|
||||||
|
?.map((userBalance) => UserBalance.fromMap(userBalance as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
settlements: (map['settlements'] as List?)
|
||||||
|
?.map((settlement) => Settlement.fromMap(settlement as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
totalExpenses: (map['totalExpenses'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
calculatedAt: _parseDateTime(map['calculatedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir en Map pour Firestore
|
||||||
|
Map<String, dynamic> 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<UserBalance>? userBalances,
|
||||||
|
List<Settlement>? 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<UserBalance> get creditors => userBalances.where((b) => b.shouldReceive).toList();
|
||||||
|
|
||||||
|
List<UserBalance> get debtors => userBalances.where((b) => b.shouldPay).toList();
|
||||||
|
|
||||||
|
int get participantCount => userBalances.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [groupId, calculatedAt];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'GroupBalance(groupId: $groupId, totalExpenses: $totalExpenses, participantCount: $participantCount, calculatedAt: $calculatedAt)';
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/models/group_statistics.dart
Normal file
25
lib/models/group_statistics.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class GroupStatistics {
|
||||||
|
final double totalExpenses;
|
||||||
|
final int expenseCount;
|
||||||
|
final double averageExpense;
|
||||||
|
final String topCategory;
|
||||||
|
final Map<String, double> 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: {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
lib/models/settlement.dart
Normal file
130
lib/models/settlement.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [fromUserId, toUserId, amount, isCompleted];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Settlement(${shortDescription}: ${formattedAmount}, status: $status)';
|
||||||
|
}
|
||||||
|
}
|
||||||
98
lib/models/user_balance.dart
Normal file
98
lib/models/user_balance.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [userId, userName, balance];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'UserBalance(userId: $userId, userName: $userName, balance: ${balance.toStringAsFixed(2)}€)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
page = const GroupContent();
|
page = const GroupContent();
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
page = const CountContent();
|
page = const AccountContent();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
page = const HomeContent();
|
page = const HomeContent();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:travel_mate/services/error_service.dart';
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
import '../data/models/group_member.dart';
|
import '../models/group_member.dart';
|
||||||
import '../data/models/account.dart';
|
import '../models/account.dart';
|
||||||
|
|
||||||
class AccountRepository {
|
class AccountRepository {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../data/models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
class AuthRepository {
|
class AuthRepository {
|
||||||
|
|||||||
146
lib/repositories/balance_repository.dart
Normal file
146
lib/repositories/balance_repository.dart
Normal file
@@ -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<GroupBalance> 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<UserBalance> _calculateUserBalances(List<Expense> expenses) {
|
||||||
|
final Map<String, Map<String, dynamic>> 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<Settlement> _calculateOptimalSettlements(List<UserBalance> balances) {
|
||||||
|
final settlements = <Settlement>[];
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
lib/repositories/expense_repository.dart
Normal file
113
lib/repositories/expense_repository.dart
Normal file
@@ -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<List<Expense>> 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<String, dynamic>, doc.id))
|
||||||
|
.toList();
|
||||||
|
}).handleError((error) {
|
||||||
|
_errorService.logError('ExpenseRepository', 'Erreur stream expenses: $error');
|
||||||
|
return <Expense>[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une dépense
|
||||||
|
Future<String> 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<void> 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<void> 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<void> 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<String, dynamic>,
|
||||||
|
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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:travel_mate/services/error_service.dart';
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
import '../data/models/group.dart';
|
import '../models/group.dart';
|
||||||
import '../data/models/group_member.dart';
|
import '../models/group_member.dart';
|
||||||
|
|
||||||
class GroupRepository {
|
class GroupRepository {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../data/models/message.dart';
|
import '../models/message.dart';
|
||||||
|
|
||||||
class MessageRepository {
|
class MessageRepository {
|
||||||
final FirebaseFirestore _firestore;
|
final FirebaseFirestore _firestore;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../data/models/trip.dart';
|
import '../models/trip.dart';
|
||||||
|
|
||||||
class TripRepository {
|
class TripRepository {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../data/models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
class UserRepository {
|
class UserRepository {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:travel_mate/services/error_service.dart';
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
import '../data/models/account.dart';
|
import '../models/account.dart';
|
||||||
|
|
||||||
class AccountService {
|
class AccountService {
|
||||||
final _errorService = ErrorService();
|
final _errorService = ErrorService();
|
||||||
|
|||||||
267
lib/services/balance_service.dart
Normal file
267
lib/services/balance_service.dart
Normal file
@@ -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<GroupBalance> 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<GroupBalance> 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<UserBalance> calculateUserBalances(List<Expense> expenses) {
|
||||||
|
final Map<String, _UserBalanceCalculator> 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<Settlement> optimizeSettlements(List<UserBalance> balances) {
|
||||||
|
final settlements = <Settlement>[];
|
||||||
|
|
||||||
|
// 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<String, double>.fromEntries(
|
||||||
|
creditors.map((c) => MapEntry(c.userId, c.balance))
|
||||||
|
);
|
||||||
|
final debtorsRemaining = Map<String, double>.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<Settlement> _validateSettlements(List<Settlement> 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<String, double> analyzeCategorySpending(List<Expense> expenses) {
|
||||||
|
final categoryTotals = <String, double>{};
|
||||||
|
|
||||||
|
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<Expense> 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<void> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
lib/services/expense_service.dart
Normal file
91
lib/services/expense_service.dart
Normal file
@@ -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<String> 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<void> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
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';
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
|
|
||||||
class GroupService {
|
class GroupService {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../data/models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../repositories/message_repository.dart';
|
import '../repositories/message_repository.dart';
|
||||||
import 'error_service.dart';
|
import 'error_service.dart';
|
||||||
|
|
||||||
|
|||||||
214
lib/services/storage_service.dart
Normal file
214
lib/services/storage_service.dart
Normal file
@@ -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<String> 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<void> 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<Uint8List> _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<List<String>> uploadMultipleImages(
|
||||||
|
String groupId,
|
||||||
|
List<File> 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<FullMetadata?> 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<void> 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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:travel_mate/services/error_service.dart';
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
import '../data/models/trip.dart';
|
import '../models/trip.dart';
|
||||||
|
|
||||||
class TripService {
|
class TripService {
|
||||||
final _errorService = ErrorService();
|
final _errorService = ErrorService();
|
||||||
|
|||||||
Reference in New Issue
Block a user