feat: Implement account management features
- Added ExpenseDetailDialog for displaying expense details and actions. - Created ExpensesTab to list expenses for a group. - Developed GroupExpensesPage to manage group expenses with tabs for expenses, balances, and settlements. - Introduced SettlementsTab to show optimized repayment plans. - Refactored create_trip_content.dart to remove CountBloc and related logic. - Added Account model to manage user accounts and group members. - Replaced CountRepository with AccountRepository for account-related operations. - Removed CountService and CountRepository as part of the refactor. - Updated main.dart and home.dart to integrate new account management components.
This commit is contained in:
0
lib/blocs/account/account_bloc.dart
Normal file
0
lib/blocs/account/account_bloc.dart
Normal file
0
lib/blocs/account/account_event.dart
Normal file
0
lib/blocs/account/account_event.dart
Normal file
0
lib/blocs/account/account_state.dart
Normal file
0
lib/blocs/account/account_state.dart
Normal file
@@ -1,196 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../services/count_service.dart';
|
||||
import '../../repositories/count_repository.dart';
|
||||
import 'count_event.dart';
|
||||
import 'count_state.dart';
|
||||
|
||||
class CountBloc extends Bloc<CountEvent, CountState> {
|
||||
final CountService _countService;
|
||||
StreamSubscription<List<Expense>>? _expensesSubscription;
|
||||
Map<ExpenseCurrency, double> _exchangeRates = {};
|
||||
|
||||
CountBloc({CountService? countService})
|
||||
: _countService = countService ?? CountService(
|
||||
countRepository: CountRepository(),
|
||||
),
|
||||
super(CountInitial()) {
|
||||
on<LoadExpenses>(_onLoadExpenses);
|
||||
on<CreateExpense>(_onCreateExpense);
|
||||
on<UpdateExpense>(_onUpdateExpense);
|
||||
on<DeleteExpense>(_onDeleteExpense);
|
||||
on<ArchiveExpense>(_onArchiveExpense);
|
||||
on<MarkSplitAsPaid>(_onMarkSplitAsPaid);
|
||||
on<LoadExchangeRates>(_onLoadExchangeRates);
|
||||
on<_ExpensesUpdated>(_onExpensesUpdated);
|
||||
}
|
||||
|
||||
Future<void> _onLoadExpenses(
|
||||
LoadExpenses event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
emit(CountLoading());
|
||||
|
||||
// Charger les taux de change
|
||||
if (_exchangeRates.isEmpty) {
|
||||
_exchangeRates = await _countService.getExchangeRates();
|
||||
}
|
||||
|
||||
await _expensesSubscription?.cancel();
|
||||
|
||||
_expensesSubscription = _countService
|
||||
.getExpensesStream(event.groupId, includeArchived: event.includeArchived)
|
||||
.listen(
|
||||
(expenses) {
|
||||
add(_ExpensesUpdated(
|
||||
groupId: event.groupId,
|
||||
expenses: expenses,
|
||||
));
|
||||
},
|
||||
onError: (error) {
|
||||
add(_ExpensesError('Erreur lors du chargement des dépenses: $error'));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onExpensesUpdated(
|
||||
_ExpensesUpdated event,
|
||||
Emitter<CountState> emit,
|
||||
) {
|
||||
// Récupérer les membres du groupe et calculer les balances
|
||||
final memberIds = <String>{};
|
||||
final memberNames = <String, String>{};
|
||||
|
||||
for (final expense in event.expenses) {
|
||||
memberIds.add(expense.paidById);
|
||||
memberNames[expense.paidById] = expense.paidByName;
|
||||
|
||||
for (final split in expense.splits) {
|
||||
memberIds.add(split.userId);
|
||||
memberNames[split.userId] = split.userName;
|
||||
}
|
||||
}
|
||||
|
||||
final balances = _countService.calculateBalances(
|
||||
event.expenses,
|
||||
memberIds.toList(),
|
||||
memberNames,
|
||||
);
|
||||
|
||||
final settlements = _countService.calculateOptimizedSettlements(balances);
|
||||
|
||||
emit(ExpensesLoaded(
|
||||
groupId: event.groupId,
|
||||
expenses: event.expenses,
|
||||
balances: balances,
|
||||
settlements: settlements,
|
||||
exchangeRates: _exchangeRates,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _onCreateExpense(
|
||||
CreateExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.createExpense(
|
||||
event.expense,
|
||||
receiptImage: event.receiptImage,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la création de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateExpense(
|
||||
UpdateExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.updateExpense(
|
||||
event.expense,
|
||||
newReceiptImage: event.newReceiptImage,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la modification de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleteExpense(
|
||||
DeleteExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.deleteExpense(event.groupId, event.expenseId);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de la suppression de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onArchiveExpense(
|
||||
ArchiveExpense event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.archiveExpense(event.groupId, event.expenseId);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors de l\'archivage de la dépense: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onMarkSplitAsPaid(
|
||||
MarkSplitAsPaid event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _countService.markSplitAsPaid(
|
||||
event.groupId,
|
||||
event.expenseId,
|
||||
event.userId,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors du marquage du paiement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadExchangeRates(
|
||||
LoadExchangeRates event,
|
||||
Emitter<CountState> emit,
|
||||
) async {
|
||||
try {
|
||||
_exchangeRates = await _countService.getExchangeRates();
|
||||
} catch (e) {
|
||||
emit(CountError('Erreur lors du chargement des taux de change: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_expensesSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Events internes
|
||||
class _ExpensesUpdated extends CountEvent {
|
||||
final String groupId;
|
||||
final List<Expense> expenses;
|
||||
|
||||
const _ExpensesUpdated({
|
||||
required this.groupId,
|
||||
required this.expenses,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenses];
|
||||
}
|
||||
|
||||
class _ExpensesError extends CountEvent {
|
||||
final String error;
|
||||
|
||||
const _ExpensesError(this.error);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
|
||||
abstract class CountEvent extends Equatable {
|
||||
const CountEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadExpenses extends CountEvent {
|
||||
final String groupId;
|
||||
final bool includeArchived;
|
||||
|
||||
const LoadExpenses(this.groupId, {this.includeArchived = false});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, includeArchived];
|
||||
}
|
||||
|
||||
class CreateExpense extends CountEvent {
|
||||
final Expense expense;
|
||||
final File? receiptImage;
|
||||
|
||||
const CreateExpense({
|
||||
required this.expense,
|
||||
this.receiptImage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [expense, receiptImage];
|
||||
}
|
||||
|
||||
class UpdateExpense extends CountEvent {
|
||||
final Expense expense;
|
||||
final File? newReceiptImage;
|
||||
|
||||
const UpdateExpense({
|
||||
required this.expense,
|
||||
this.newReceiptImage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [expense, newReceiptImage];
|
||||
}
|
||||
|
||||
class DeleteExpense extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
|
||||
const DeleteExpense({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId];
|
||||
}
|
||||
|
||||
class ArchiveExpense extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
|
||||
const ArchiveExpense({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId];
|
||||
}
|
||||
|
||||
class MarkSplitAsPaid extends CountEvent {
|
||||
final String groupId;
|
||||
final String expenseId;
|
||||
final String userId;
|
||||
|
||||
const MarkSplitAsPaid({
|
||||
required this.groupId,
|
||||
required this.expenseId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenseId, userId];
|
||||
}
|
||||
|
||||
class LoadExchangeRates extends CountEvent {
|
||||
const LoadExchangeRates();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
import '../../data/models/balance.dart';
|
||||
|
||||
abstract class CountState extends Equatable {
|
||||
const CountState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class CountInitial extends CountState {}
|
||||
|
||||
class CountLoading extends CountState {}
|
||||
|
||||
class ExpensesLoaded extends CountState {
|
||||
final String groupId;
|
||||
final List<Expense> expenses;
|
||||
final List<Balance> balances;
|
||||
final List<Settlement> settlements;
|
||||
final Map<ExpenseCurrency, double> exchangeRates;
|
||||
|
||||
const ExpensesLoaded({
|
||||
required this.groupId,
|
||||
required this.expenses,
|
||||
required this.balances,
|
||||
required this.settlements,
|
||||
required this.exchangeRates,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [groupId, expenses, balances, settlements, exchangeRates];
|
||||
}
|
||||
|
||||
class CountError extends CountState {
|
||||
final String message;
|
||||
|
||||
const CountError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/count/count_state.dart';
|
||||
import '../../blocs/account/account_bloc.dart';
|
||||
import '../../blocs/account/account_event.dart';
|
||||
import '../../blocs/account/account_state.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/group.dart';
|
||||
import '../../data/models/expense.dart';
|
||||
@@ -148,7 +148,7 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
|
||||
try {
|
||||
// Convertir en EUR
|
||||
final amountInEur = await context.read<CountBloc>().state is ExpensesLoaded
|
||||
final amountInEur = context.read<CountBloc>().state is ExpensesLoaded
|
||||
? (context.read<CountBloc>().state as ExpensesLoaded)
|
||||
.exchangeRates[_selectedCurrency]! * amount
|
||||
: amount;
|
||||
@@ -435,7 +435,7 @@ class _AddExpenseDialogState extends State<AddExpenseDialog> {
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/account/account_bloc.dart';
|
||||
import '../../blocs/account/account_event.dart';
|
||||
import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/expense.dart';
|
||||
@@ -67,7 +67,7 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../blocs/count/count_state.dart';
|
||||
import '../../blocs/account/account_bloc.dart';
|
||||
import '../../blocs/account/account_event.dart';
|
||||
import '../../blocs/account/account_state.dart';
|
||||
import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../data/models/group.dart';
|
||||
@@ -9,8 +9,6 @@ import '../../blocs/trip/trip_event.dart';
|
||||
import '../../blocs/trip/trip_state.dart';
|
||||
import '../../blocs/group/group_bloc.dart';
|
||||
import '../../blocs/group/group_event.dart';
|
||||
import '../../blocs/count/count_bloc.dart';
|
||||
import '../../blocs/count/count_event.dart';
|
||||
import '../../data/models/group.dart';
|
||||
import '../../data/models/group_member.dart';
|
||||
import '../../services/user_service.dart';
|
||||
@@ -572,7 +570,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
final group = Group(
|
||||
id: '', // Sera généré par Firestore
|
||||
name: _titleController.text.trim(),
|
||||
tripId: tripId,
|
||||
tripId: tripId, // ✅ ID du voyage récupéré
|
||||
createdBy: currentUser.id,
|
||||
);
|
||||
|
||||
@@ -591,15 +589,11 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
)),
|
||||
];
|
||||
|
||||
// Créer le groupe
|
||||
context.read<GroupBloc>().add(CreateGroupWithMembers(
|
||||
group: group,
|
||||
members: groupMembers,
|
||||
));
|
||||
|
||||
// ✅ AJOUT : Attendre un court instant pour que le groupe soit créé
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
48
lib/data/models/account.dart
Normal file
48
lib/data/models/account.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'group_member.dart';
|
||||
|
||||
class Account {
|
||||
final String id;
|
||||
final String tripId;
|
||||
final String groupId;
|
||||
final List<GroupMember> members;
|
||||
|
||||
Account({
|
||||
required this.id,
|
||||
required this.tripId,
|
||||
required this.groupId,
|
||||
List<GroupMember>? members,
|
||||
}) : members = members ?? [];
|
||||
|
||||
|
||||
factory Account.fromMap(Map<String, dynamic> map) {
|
||||
return Account(
|
||||
id: map['id'] as String,
|
||||
tripId: map['tripId'] as String,
|
||||
groupId: map['groupId'] as String,
|
||||
members: [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'tripId': tripId,
|
||||
'groupId': groupId,
|
||||
'members': members.map((member) => member.toMap()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
Account copyWith({
|
||||
String? id,
|
||||
String? tripId,
|
||||
String? groupId,
|
||||
List<GroupMember>? members,
|
||||
}) {
|
||||
return Account(
|
||||
id: id ?? this.id,
|
||||
tripId: tripId ?? this.tripId,
|
||||
groupId: groupId ?? this.groupId,
|
||||
members: members ?? this.members,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import 'blocs/theme/theme_state.dart';
|
||||
import 'blocs/group/group_bloc.dart';
|
||||
import 'blocs/user/user_bloc.dart';
|
||||
import 'blocs/trip/trip_bloc.dart';
|
||||
import 'blocs/count/count_bloc.dart';
|
||||
import 'blocs/account/account_bloc.dart';
|
||||
import 'repositories/auth_repository.dart';
|
||||
import 'repositories/trip_repository.dart';
|
||||
import 'repositories/user_repository.dart';
|
||||
import 'repositories/group_repository.dart';
|
||||
import 'repositories/message_repository.dart';
|
||||
import 'repositories/count_repository.dart';
|
||||
import 'repositories/account_repository.dart';
|
||||
import 'pages/login.dart';
|
||||
import 'pages/home.dart';
|
||||
import 'pages/signup.dart';
|
||||
@@ -54,9 +54,7 @@ class MyApp extends StatelessWidget {
|
||||
RepositoryProvider<MessageRepository>(
|
||||
create: (context) => MessageRepository(),
|
||||
),
|
||||
RepositoryProvider<CountRepository>(
|
||||
create: (context) => CountRepository(),
|
||||
),
|
||||
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
@@ -79,9 +77,7 @@ class MyApp extends StatelessWidget {
|
||||
BlocProvider(
|
||||
create: (context) => MessageBloc(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => CountBloc(),
|
||||
),
|
||||
|
||||
],
|
||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||
builder: (context, themeState) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../components/home/home_content.dart';
|
||||
import '../components/settings/settings_content.dart';
|
||||
import '../components/map/map_content.dart';
|
||||
import '../components/group/group_content.dart';
|
||||
import '../components/count/count_content.dart';
|
||||
import '../components/account/account_content.dart';
|
||||
import '../blocs/user/user_bloc.dart';
|
||||
import '../blocs/user/user_event.dart';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
|
||||
117
lib/repositories/account_repository.dart
Normal file
117
lib/repositories/account_repository.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
import '../data/models/group_member.dart';
|
||||
import '../data/models/account.dart';
|
||||
|
||||
class AccountRepository {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final _errorService = ErrorService();
|
||||
|
||||
CollectionReference get _accountCollection => _firestore.collection('accounts');
|
||||
|
||||
CollectionReference _membersCollection(String accountId) {
|
||||
return _accountCollection.doc(accountId).collection('members');
|
||||
}
|
||||
|
||||
Future<String> createAccountWithMembers({
|
||||
required Account account,
|
||||
required List<GroupMember> members,
|
||||
}) async {
|
||||
try {
|
||||
return await _firestore.runTransaction<String>((transaction) async {
|
||||
final accountRef = _accountCollection.doc();
|
||||
|
||||
final accountData = account.toMap();
|
||||
|
||||
transaction.set(accountRef, accountData);
|
||||
|
||||
for (var member in members) {
|
||||
final memberRef = accountRef.collection('members').doc(member.userId);
|
||||
transaction.set(memberRef, member.toMap());
|
||||
}
|
||||
|
||||
return accountRef.id;
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création du compte: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<Account>> getAccountByUserId(String userId) {
|
||||
return _accountCollection
|
||||
.snapshots()
|
||||
.asyncMap((snapshot) async {
|
||||
|
||||
List<Account> userAccounts = [];
|
||||
|
||||
for (var accountDoc in snapshot.docs) {
|
||||
try {
|
||||
final accountId = accountDoc.id;
|
||||
|
||||
final memberDoc = await accountDoc.reference
|
||||
.collection('members')
|
||||
.doc(userId)
|
||||
.get();
|
||||
if (memberDoc.exists) {
|
||||
final accountData = accountDoc.data() as Map<String, dynamic>;
|
||||
final account = Account.fromMap(accountData);
|
||||
final members = await getAccountMembers(accountId);
|
||||
|
||||
userAccounts.add(account.copyWith(members: members));
|
||||
} else {
|
||||
_errorService.logInfo('account_repository.dart', 'Utilisateur NON membre de $accountId');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_errorService.logError(e.toString(), stackTrace);
|
||||
}
|
||||
}
|
||||
return userAccounts;
|
||||
})
|
||||
.distinct((prev, next) {
|
||||
if (prev.length != next.length) return false;
|
||||
final prevIds = prev.map((a) => a.id).toSet();
|
||||
final nextIds = next.map((a) => a.id).toSet();
|
||||
|
||||
final identical = prevIds.difference(nextIds).isEmpty &&
|
||||
nextIds.difference(prevIds).isEmpty;
|
||||
|
||||
return identical;
|
||||
})
|
||||
.handleError((error, stackTrace) {
|
||||
_errorService.logError(error, stackTrace);
|
||||
return <Account>[];
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<GroupMember>> getAccountMembers(String accountId) async {
|
||||
try {
|
||||
final snapshot = await _membersCollection(accountId).get();
|
||||
return snapshot.docs
|
||||
.map((doc) {
|
||||
return GroupMember.fromMap(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des membres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<DocumentSnapshot> getAccountById(String accountId) async {
|
||||
return await _firestore.collection('accounts').doc(accountId).get();
|
||||
}
|
||||
|
||||
Future<void> createAccount(Map<String, dynamic> accountData) async {
|
||||
await _firestore.collection('accounts').add(accountData);
|
||||
}
|
||||
|
||||
Future<void> updateAccount(String accountId, Map<String, dynamic> accountData) async {
|
||||
await _firestore.collection('accounts').doc(accountId).update(accountData);
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String accountId) async {
|
||||
await _firestore.collection('accounts').doc(accountId).delete();
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'dart:io';
|
||||
import '../data/models/expense.dart';
|
||||
|
||||
class CountRepository {
|
||||
final FirebaseFirestore _firestore;
|
||||
final FirebaseStorage _storage;
|
||||
|
||||
CountRepository({
|
||||
FirebaseFirestore? firestore,
|
||||
FirebaseStorage? storage,
|
||||
}) : _firestore = firestore ?? FirebaseFirestore.instance,
|
||||
_storage = storage ?? FirebaseStorage.instance;
|
||||
|
||||
// Créer une dépense
|
||||
Future<String> createExpense(Expense expense) async {
|
||||
final docRef = await _firestore
|
||||
.collection('groups')
|
||||
.doc(expense.groupId)
|
||||
.collection('expenses')
|
||||
.add(expense.toMap());
|
||||
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
// Mettre à jour une dépense
|
||||
Future<void> updateExpense(String groupId, Expense expense) async {
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expense.id)
|
||||
.update(expense.toMap());
|
||||
}
|
||||
|
||||
// Supprimer une dépense
|
||||
Future<void> deleteExpense(String groupId, String expenseId) async {
|
||||
final expense = await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.get();
|
||||
|
||||
final data = expense.data();
|
||||
if (data != null && data['receiptUrl'] != null) {
|
||||
await deleteReceipt(data['receiptUrl']);
|
||||
}
|
||||
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.delete();
|
||||
}
|
||||
|
||||
// Archiver une dépense
|
||||
Future<void> archiveExpense(String groupId, String expenseId) async {
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.update({'isArchived': true});
|
||||
}
|
||||
|
||||
// Marquer une split comme payée
|
||||
Future<void> markSplitAsPaid({
|
||||
required String groupId,
|
||||
required String expenseId,
|
||||
required String userId,
|
||||
}) async {
|
||||
final doc = await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.get();
|
||||
|
||||
final expense = Expense.fromFirestore(doc);
|
||||
final updatedSplits = expense.splits.map((split) {
|
||||
if (split.userId == userId) {
|
||||
return ExpenseSplit(
|
||||
userId: split.userId,
|
||||
userName: split.userName,
|
||||
amount: split.amount,
|
||||
isPaid: true,
|
||||
);
|
||||
}
|
||||
return split;
|
||||
}).toList();
|
||||
|
||||
await _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.doc(expenseId)
|
||||
.update({
|
||||
'splits': updatedSplits.map((s) => s.toMap()).toList(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stream des dépenses d'un groupe
|
||||
Stream<List<Expense>> getExpensesStream(String groupId, {bool includeArchived = false}) {
|
||||
Query query = _firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.collection('expenses')
|
||||
.orderBy('date', descending: true);
|
||||
|
||||
if (!includeArchived) {
|
||||
query = query.where('isArchived', isEqualTo: false);
|
||||
}
|
||||
|
||||
return query.snapshots().map((snapshot) {
|
||||
return snapshot.docs.map((doc) => Expense.fromFirestore(doc)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// Uploader un reçu
|
||||
Future<String> uploadReceipt(String groupId, String expenseId, File imageFile) async {
|
||||
final fileName = 'receipts/$groupId/$expenseId/${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final ref = _storage.ref().child(fileName);
|
||||
|
||||
final uploadTask = await ref.putFile(imageFile);
|
||||
return await uploadTask.ref.getDownloadURL();
|
||||
}
|
||||
|
||||
// Supprimer un reçu
|
||||
Future<void> deleteReceipt(String receiptUrl) async {
|
||||
try {
|
||||
final ref = _storage.refFromURL(receiptUrl);
|
||||
await ref.delete();
|
||||
} catch (e) {
|
||||
// Le fichier n'existe peut-être plus
|
||||
print('Erreur lors de la suppression du reçu: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les taux de change (API externe ou valeurs fixes)
|
||||
Future<Map<ExpenseCurrency, double>> getExchangeRates() async {
|
||||
// TODO: Intégrer une API de taux de change réels
|
||||
// Pour l'instant, valeurs approximatives
|
||||
return {
|
||||
ExpenseCurrency.eur: 1.0,
|
||||
ExpenseCurrency.usd: 0.92,
|
||||
ExpenseCurrency.gbp: 1.17,
|
||||
ExpenseCurrency.jpy: 0.0062,
|
||||
ExpenseCurrency.chf: 1.05,
|
||||
ExpenseCurrency.cad: 0.68,
|
||||
ExpenseCurrency.aud: 0.61,
|
||||
};
|
||||
}
|
||||
|
||||
// Convertir un montant en EUR
|
||||
Future<double> convertToEur(double amount, ExpenseCurrency currency) async {
|
||||
if (currency == ExpenseCurrency.eur) return amount;
|
||||
|
||||
final rates = await getExchangeRates();
|
||||
final rate = rates[currency] ?? 1.0;
|
||||
return amount * rate;
|
||||
}
|
||||
}
|
||||
0
lib/services/account_service.dart
Normal file
0
lib/services/account_service.dart
Normal file
@@ -1,226 +0,0 @@
|
||||
import 'dart:io';
|
||||
import '../data/models/expense.dart';
|
||||
import '../data/models/balance.dart';
|
||||
import '../repositories/count_repository.dart';
|
||||
import 'error_service.dart';
|
||||
|
||||
class CountService {
|
||||
final CountRepository _countRepository;
|
||||
final ErrorService _errorService;
|
||||
|
||||
CountService({
|
||||
CountRepository? countRepository,
|
||||
ErrorService? errorService,
|
||||
}) : _countRepository = countRepository ?? CountRepository(),
|
||||
_errorService = errorService ?? ErrorService();
|
||||
|
||||
// Créer une dépense
|
||||
Future<String> createExpense(Expense expense, {File? receiptImage}) async {
|
||||
try {
|
||||
final expenseId = await _countRepository.createExpense(expense);
|
||||
|
||||
if (receiptImage != null) {
|
||||
final receiptUrl = await _countRepository.uploadReceipt(
|
||||
expense.groupId,
|
||||
expenseId,
|
||||
receiptImage,
|
||||
);
|
||||
|
||||
final updatedExpense = expense.copyWith(
|
||||
id: expenseId,
|
||||
receiptUrl: receiptUrl,
|
||||
);
|
||||
|
||||
await _countRepository.updateExpense(expense.groupId, updatedExpense);
|
||||
}
|
||||
|
||||
return expenseId;
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la création de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une dépense
|
||||
Future<void> updateExpense(Expense expense, {File? newReceiptImage}) async {
|
||||
try {
|
||||
if (newReceiptImage != null) {
|
||||
// Supprimer l'ancien reçu si existe
|
||||
if (expense.receiptUrl != null) {
|
||||
await _countRepository.deleteReceipt(expense.receiptUrl!);
|
||||
}
|
||||
|
||||
// Uploader le nouveau
|
||||
final receiptUrl = await _countRepository.uploadReceipt(
|
||||
expense.groupId,
|
||||
expense.id,
|
||||
newReceiptImage,
|
||||
);
|
||||
|
||||
expense = expense.copyWith(receiptUrl: receiptUrl);
|
||||
}
|
||||
|
||||
await _countRepository.updateExpense(expense.groupId, expense);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la mise à jour de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une dépense
|
||||
Future<void> deleteExpense(String groupId, String expenseId) async {
|
||||
try {
|
||||
await _countRepository.deleteExpense(groupId, expenseId);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la suppression de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Archiver une dépense
|
||||
Future<void> archiveExpense(String groupId, String expenseId) async {
|
||||
try {
|
||||
await _countRepository.archiveExpense(groupId, expenseId);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de l\'archivage de la dépense: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Marquer une split comme payée
|
||||
Future<void> markSplitAsPaid(String groupId, String expenseId, String userId) async {
|
||||
try {
|
||||
await _countRepository.markSplitAsPaid(
|
||||
groupId: groupId,
|
||||
expenseId: expenseId,
|
||||
userId: userId,
|
||||
);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors du marquage du paiement: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream des dépenses
|
||||
Stream<List<Expense>> getExpensesStream(String groupId, {bool includeArchived = false}) {
|
||||
try {
|
||||
return _countRepository.getExpensesStream(groupId, includeArchived: includeArchived);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la récupération des dépenses: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer les balances
|
||||
List<Balance> calculateBalances(List<Expense> expenses, List<String> memberIds, Map<String, String> memberNames) {
|
||||
final balances = <String, Balance>{};
|
||||
|
||||
// Initialiser les balances
|
||||
for (final memberId in memberIds) {
|
||||
balances[memberId] = Balance(
|
||||
userId: memberId,
|
||||
userName: memberNames[memberId] ?? 'Unknown',
|
||||
totalPaid: 0,
|
||||
totalOwed: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculer pour chaque dépense
|
||||
for (final expense in expenses) {
|
||||
if (expense.isArchived) continue;
|
||||
|
||||
// Ajouter au total payé
|
||||
final payer = balances[expense.paidById];
|
||||
if (payer != null) {
|
||||
balances[expense.paidById] = Balance(
|
||||
userId: payer.userId,
|
||||
userName: payer.userName,
|
||||
totalPaid: payer.totalPaid + expense.amountInEur,
|
||||
totalOwed: payer.totalOwed,
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter au total dû pour chaque split
|
||||
for (final split in expense.splits) {
|
||||
if (!split.isPaid) {
|
||||
final debtor = balances[split.userId];
|
||||
if (debtor != null) {
|
||||
balances[split.userId] = Balance(
|
||||
userId: debtor.userId,
|
||||
userName: debtor.userName,
|
||||
totalPaid: debtor.totalPaid,
|
||||
totalOwed: debtor.totalOwed + split.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return balances.values.toList();
|
||||
}
|
||||
|
||||
// Calculer les remboursements optimisés
|
||||
List<Settlement> calculateOptimizedSettlements(List<Balance> balances) {
|
||||
final settlements = <Settlement>[];
|
||||
|
||||
// Créer des copies mutables
|
||||
final creditors = balances.where((b) => b.shouldReceive).map((b) =>
|
||||
{'userId': b.userId, 'userName': b.userName, 'amount': b.balance}
|
||||
).toList();
|
||||
|
||||
final debtors = balances.where((b) => b.shouldPay).map((b) =>
|
||||
{'userId': b.userId, 'userName': b.userName, 'amount': b.absoluteBalance}
|
||||
).toList();
|
||||
|
||||
// Trier par montant décroissant
|
||||
creditors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double));
|
||||
debtors.sort((a, b) => (b['amount'] as double).compareTo(a['amount'] as double));
|
||||
|
||||
int i = 0, j = 0;
|
||||
while (i < creditors.length && j < debtors.length) {
|
||||
final creditor = creditors[i];
|
||||
final debtor = debtors[j];
|
||||
|
||||
final creditorAmount = creditor['amount'] as double;
|
||||
final debtorAmount = debtor['amount'] as double;
|
||||
|
||||
final settleAmount = creditorAmount < debtorAmount ? creditorAmount : debtorAmount;
|
||||
|
||||
settlements.add(Settlement(
|
||||
fromUserId: debtor['userId'] as String,
|
||||
fromUserName: debtor['userName'] as String,
|
||||
toUserId: creditor['userId'] as String,
|
||||
toUserName: creditor['userName'] as String,
|
||||
amount: settleAmount,
|
||||
));
|
||||
|
||||
creditor['amount'] = creditorAmount - settleAmount;
|
||||
debtor['amount'] = debtorAmount - settleAmount;
|
||||
|
||||
if (creditor['amount'] == 0) i++;
|
||||
if (debtor['amount'] == 0) j++;
|
||||
}
|
||||
|
||||
return settlements;
|
||||
}
|
||||
|
||||
// Convertir un montant en EUR
|
||||
Future<double> convertToEur(double amount, ExpenseCurrency currency) async {
|
||||
try {
|
||||
return await _countRepository.convertToEur(amount, currency);
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la conversion: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les taux de change
|
||||
Future<Map<ExpenseCurrency, double>> getExchangeRates() async {
|
||||
try {
|
||||
return await _countRepository.getExchangeRates();
|
||||
} catch (e) {
|
||||
_errorService.logError('count_service.dart', 'Erreur lors de la récupération des taux: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user