feat: Implement group balance and expense management with new navigation and data handling

This commit is contained in:
Dayron
2025-10-28 13:06:42 +01:00
parent 94f1abfbc7
commit df1bb6da4a
10 changed files with 390 additions and 54 deletions

View File

@@ -5,6 +5,7 @@ import '../../services/balance_service.dart';
import '../../services/error_service.dart'; import '../../services/error_service.dart';
import 'balance_event.dart'; import 'balance_event.dart';
import 'balance_state.dart'; import 'balance_state.dart';
class BalanceBloc extends Bloc<BalanceEvent, BalanceState> { class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
final BalanceRepository _balanceRepository; final BalanceRepository _balanceRepository;
final BalanceService _balanceService; final BalanceService _balanceService;
@@ -19,20 +20,28 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
_balanceService = balanceService ?? BalanceService(balanceRepository: balanceRepository, expenseRepository: expenseRepository), _balanceService = balanceService ?? BalanceService(balanceRepository: balanceRepository, expenseRepository: expenseRepository),
_errorService = errorService ?? ErrorService(), _errorService = errorService ?? ErrorService(),
super(BalanceInitial()) { super(BalanceInitial()) {
on<LoadGroupBalance>(_onLoadGroupBalance); on<LoadGroupBalances>(_onLoadGroupBalance);
on<RefreshBalance>(_onRefreshBalance); on<RefreshBalance>(_onRefreshBalance);
on<MarkSettlementAsCompleted>(_onMarkSettlementAsCompleted); on<MarkSettlementAsCompleted>(_onMarkSettlementAsCompleted);
} }
Future<void> _onLoadGroupBalance( Future<void> _onLoadGroupBalance(
LoadGroupBalance event, LoadGroupBalances event,
Emitter<BalanceState> emit, Emitter<BalanceState> emit,
) async { ) async {
try { try {
emit(BalanceLoading()); emit(BalanceLoading());
final groupBalance = await _balanceRepository.calculateGroupBalance(event.groupId); // Calculer les balances du groupe
emit(BalanceLoaded(groupBalance)); final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId);
// Calculer les règlements optimisés
final settlements = await _balanceService.calculateOptimalSettlements(event.groupId);
emit(GroupBalancesLoaded(
balances: userBalances,
settlements: settlements,
));
} catch (e) { } catch (e) {
_errorService.logError('BalanceBloc', 'Erreur chargement balance: $e'); _errorService.logError('BalanceBloc', 'Erreur chargement balance: $e');
emit(BalanceError(e.toString())); emit(BalanceError(e.toString()));
@@ -45,12 +54,20 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
) async { ) async {
try { try {
// Garde l'état actuel pendant le refresh si possible // Garde l'état actuel pendant le refresh si possible
if (state is! BalanceLoaded) { if (state is! GroupBalancesLoaded) {
emit(BalanceLoading()); emit(BalanceLoading());
} }
final groupBalance = await _balanceRepository.calculateGroupBalance(event.groupId); // Calculer les balances du groupe
emit(BalanceLoaded(groupBalance)); final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId);
// Calculer les règlements optimisés
final settlements = await _balanceService.calculateOptimalSettlements(event.groupId);
emit(GroupBalancesLoaded(
balances: userBalances,
settlements: settlements,
));
} catch (e) { } catch (e) {
_errorService.logError('BalanceBloc', 'Erreur refresh balance: $e'); _errorService.logError('BalanceBloc', 'Erreur refresh balance: $e');
emit(BalanceError(e.toString())); emit(BalanceError(e.toString()));

View File

@@ -7,13 +7,13 @@ abstract class BalanceEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
class LoadGroupBalance extends BalanceEvent { class LoadGroupBalances extends BalanceEvent {
final String groupId; final String groupId;
const LoadGroupBalance(this.groupId); const LoadGroupBalances(this.groupId);
@override @override
List<Object?> get props => [groupId]; List<Object> get props => [groupId];
} }
class RefreshBalance extends BalanceEvent { class RefreshBalance extends BalanceEvent {

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/group_balance.dart'; import '../../models/settlement.dart';
import '../../models/user_balance.dart';
abstract class BalanceState extends Equatable { abstract class BalanceState extends Equatable {
const BalanceState(); const BalanceState();
@@ -12,13 +13,17 @@ class BalanceInitial extends BalanceState {}
class BalanceLoading extends BalanceState {} class BalanceLoading extends BalanceState {}
class BalanceLoaded extends BalanceState { class GroupBalancesLoaded extends BalanceState {
final GroupBalance groupBalance; final List<UserBalance> balances;
final List<Settlement> settlements;
const BalanceLoaded(this.groupBalance); const GroupBalancesLoaded({
required this.balances,
required this.settlements,
});
@override @override
List<Object?> get props => [groupBalance]; List<Object> get props => [balances, settlements];
} }
class BalanceOperationSuccess extends BalanceState { class BalanceOperationSuccess extends BalanceState {

View File

@@ -7,7 +7,8 @@ import '../../blocs/account/account_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/components/error/error_content.dart'; import 'package:travel_mate/components/error/error_content.dart';
import '../../blocs/user/user_state.dart' as user_state; import '../../blocs/user/user_state.dart' as user_state;
import '../../repositories/group_repository.dart'; // Ajouter cet import
import 'group_expenses_page.dart'; // Ajouter cet import
class AccountContent extends StatefulWidget { class AccountContent extends StatefulWidget {
const AccountContent({super.key}); const AccountContent({super.key});
@@ -17,6 +18,8 @@ class AccountContent extends StatefulWidget {
} }
class _AccountContentState extends State<AccountContent> { class _AccountContentState extends State<AccountContent> {
final _groupRepository = GroupRepository(); // Ajouter cette ligne
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -45,6 +48,44 @@ class _AccountContentState extends State<AccountContent> {
} }
} }
// Nouvelle méthode pour naviguer vers la page des dépenses de groupe
Future<void> _navigateToGroupExpenses(Account account) async {
try {
// Récupérer le groupe associé au compte
final group = await _groupRepository.getGroupByTripId(account.tripId);
if (group != null && mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GroupExpensesPage(
account: account,
group: group,
),
),
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Groupe non trouvé pour ce compte'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du chargement du groupe: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<UserBloc, user_state.UserState>( return BlocBuilder<UserBloc, user_state.UserState>(
@@ -221,7 +262,7 @@ class _AccountContentState extends State<AccountContent> {
), ),
subtitle: Text(memberInfo), subtitle: Text(memberInfo),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () {}, onTap: () => _navigateToGroupExpenses(account), // Modifier cette ligne
), ),
); );
} catch (e) { } catch (e) {
@@ -234,6 +275,4 @@ class _AccountContentState extends State<AccountContent> {
); );
} }
} }
} }

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/expense/expense_bloc.dart';
import '../../blocs/expense/expense_event.dart';
import '../../blocs/expense/expense_state.dart';
import '../../blocs/balance/balance_bloc.dart';
import '../../blocs/balance/balance_event.dart';
import '../../blocs/balance/balance_state.dart';
import '../../blocs/user/user_bloc.dart';
import '../../blocs/user/user_state.dart' as user_state;
import '../../models/account.dart';
import '../../models/group.dart';
import 'add_expense_dialog.dart';
import 'balances_tab.dart';
import 'expenses_tab.dart';
import 'settlements_tab.dart';
class GroupExpensesPage extends StatefulWidget {
final Account account;
final Group group;
const GroupExpensesPage({
super.key,
required this.account,
required this.group,
});
@override
State<GroupExpensesPage> createState() => _GroupExpensesPageState();
}
class _GroupExpensesPageState extends State<GroupExpensesPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_loadData();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadData() {
// Charger les dépenses du groupe
context.read<ExpenseBloc>().add(LoadExpensesByGroup(widget.group.id));
// Charger les balances du groupe
context.read<BalanceBloc>().add(LoadGroupBalances(widget.group.id));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.account.name),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
elevation: 0,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(
icon: Icon(Icons.balance),
text: 'Balances',
),
Tab(
icon: Icon(Icons.receipt_long),
text: 'Dépenses',
),
Tab(
icon: Icon(Icons.payment),
text: 'Règlements',
),
],
),
),
body: MultiBlocListener(
listeners: [
BlocListener<ExpenseBloc, ExpenseState>(
listener: (context, state) {
if (state is ExpenseOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
_loadData(); // Recharger les données après une opération
} else if (state is ExpenseError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
),
],
child: TabBarView(
controller: _tabController,
children: [
// Onglet Balances
BlocBuilder<BalanceBloc, BalanceState>(
builder: (context, state) {
if (state is BalanceLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is GroupBalancesLoaded) {
return BalancesTab(balances: state.balances);
} else if (state is BalanceError) {
return _buildErrorState('Erreur lors du chargement des balances: ${state.message}');
}
return _buildEmptyState('Aucune balance disponible');
},
),
// Onglet Dépenses
BlocBuilder<ExpenseBloc, ExpenseState>(
builder: (context, state) {
if (state is ExpenseLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ExpensesLoaded) {
return ExpensesTab(
expenses: state.expenses,
group: widget.group,
);
} else if (state is ExpenseError) {
return _buildErrorState('Erreur lors du chargement des dépenses: ${state.message}');
}
return _buildEmptyState('Aucune dépense trouvée');
},
),
// Onglet Règlements
BlocBuilder<BalanceBloc, BalanceState>(
builder: (context, state) {
if (state is BalanceLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is GroupBalancesLoaded) {
return SettlementsTab(settlements: state.settlements);
} else if (state is BalanceError) {
return _buildErrorState('Erreur lors du chargement des règlements: ${state.message}');
}
return _buildEmptyState('Aucun règlement nécessaire');
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddExpenseDialog,
heroTag: "add_expense_fab",
tooltip: 'Ajouter une dépense',
child: const Icon(Icons.add),
),
);
}
Widget _buildErrorState(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Erreur',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.red[600],
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
);
}
Widget _buildEmptyState(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune donnée',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
message,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
);
}
void _showAddExpenseDialog() {
final userState = context.read<UserBloc>().state;
if (userState is user_state.UserLoaded) {
showDialog(
context: context,
builder: (context) => AddExpenseDialog(
group: widget.group,
currentUser: userState.user,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur: utilisateur non connecté'),
backgroundColor: Colors.red,
),
);
}
}
}

View File

@@ -66,11 +66,23 @@ class MyApp extends StatelessWidget {
RepositoryProvider<ExpenseRepository>( RepositoryProvider<ExpenseRepository>(
create: (context) => ExpenseRepository(), create: (context) => ExpenseRepository(),
), ),
// Provide service instances so BLoCs can read them with context.read<T>()
RepositoryProvider<ExpenseService>(
create: (context) => ExpenseService(
expenseRepository: context.read<ExpenseRepository>(),
),
),
RepositoryProvider<BalanceRepository>( RepositoryProvider<BalanceRepository>(
create: (context) => BalanceRepository( create: (context) => BalanceRepository(
expenseRepository: context.read<ExpenseRepository>(), expenseRepository: context.read<ExpenseRepository>(),
), ),
), ),
RepositoryProvider<BalanceService>(
create: (context) => BalanceService(
balanceRepository: context.read<BalanceRepository>(),
expenseRepository: context.read<ExpenseRepository>(),
),
),
], ],
child: MultiBlocProvider( child: MultiBlocProvider(

View File

@@ -76,8 +76,6 @@ class Trip {
status: map['status'] as String? ?? 'draft', status: map['status'] as String? ?? 'draft',
); );
} catch (e) { } catch (e) {
print('❌ Erreur parsing Trip: $e');
print('Map reçue: $map');
rethrow; rethrow;
} }
} }

View File

@@ -43,6 +43,19 @@ class BalanceRepository {
} }
} }
Future<List<UserBalance>> calculateGroupUserBalances(String groupId) async {
try {
final expenses = await _expenseRepository
.getExpensesStream(groupId)
.first;
return _calculateUserBalances(expenses);
} catch (e) {
_errorService.logError('BalanceRepository', 'Erreur calcul user balances: $e');
rethrow;
}
}
// Calculer les balances individuelles // Calculer les balances individuelles
List<UserBalance> _calculateUserBalances(List<Expense> expenses) { List<UserBalance> _calculateUserBalances(List<Expense> expenses) {
final Map<String, Map<String, dynamic>> userBalanceMap = {}; final Map<String, Map<String, dynamic>> userBalanceMap = {};

View File

@@ -8,34 +8,25 @@ class TripRepository {
// Récupérer tous les voyages d'un utilisateur // Récupérer tous les voyages d'un utilisateur
Stream<List<Trip>> getTripsByUserId(String userId) { Stream<List<Trip>> getTripsByUserId(String userId) {
print('🔍 Chargement des trips pour userId: $userId');
try { try {
return _tripsCollection return _tripsCollection
.where('participants', arrayContains: userId) .where('participants', arrayContains: userId)
.snapshots() .snapshots()
.map((snapshot) { .map((snapshot) {
print('📦 Snapshot reçu: ${snapshot.docs.length} documents');
final trips = snapshot.docs final trips = snapshot.docs
.map((doc) { .map((doc) {
try { try {
final data = doc.data() as Map<String, dynamic>; final data = doc.data() as Map<String, dynamic>;
print('📄 Document ${doc.id}: ${data.keys.toList()}');
return Trip.fromMap(data, doc.id); return Trip.fromMap(data, doc.id);
} catch (e) { } catch (e) {
print('❌ Erreur parsing trip ${doc.id}: $e');
return null; return null;
} }
}) })
.whereType<Trip>() .whereType<Trip>()
.toList(); .toList();
print('${trips.length} trips parsés avec succès');
return trips; return trips;
}); });
} catch (e) { } catch (e) {
print('❌ Erreur getTripsByUserId: $e');
throw Exception('Erreur lors de la récupération des voyages: $e'); throw Exception('Erreur lors de la récupération des voyages: $e');
} }
} }
@@ -43,16 +34,11 @@ class TripRepository {
// Créer un voyage et retourner son ID // Créer un voyage et retourner son ID
Future<String> createTrip(Trip trip) async { Future<String> createTrip(Trip trip) async {
try { try {
print('📝 Création du voyage: ${trip.title}');
final tripData = trip.toMap(); final tripData = trip.toMap();
// Ne pas modifier les timestamps ici, ils sont déjà au bon format // Ne pas modifier les timestamps ici, ils sont déjà au bon format
final docRef = await _tripsCollection.add(tripData); final docRef = await _tripsCollection.add(tripData);
print('✅ Voyage créé avec ID: ${docRef.id}');
return docRef.id; return docRef.id;
} catch (e) { } catch (e) {
print('❌ Erreur création voyage: $e');
throw Exception('Erreur lors de la création du voyage: $e'); throw Exception('Erreur lors de la création du voyage: $e');
} }
} }
@@ -63,13 +49,11 @@ class TripRepository {
final doc = await _tripsCollection.doc(tripId).get(); final doc = await _tripsCollection.doc(tripId).get();
if (!doc.exists) { if (!doc.exists) {
print('⚠️ Voyage $tripId non trouvé');
return null; return null;
} }
return Trip.fromMap(doc.data() as Map<String, dynamic>, doc.id); return Trip.fromMap(doc.data() as Map<String, dynamic>, doc.id);
} catch (e) { } catch (e) {
print('❌ Erreur getTripById: $e');
throw Exception('Erreur lors de la récupération du voyage: $e'); throw Exception('Erreur lors de la récupération du voyage: $e');
} }
} }
@@ -77,17 +61,12 @@ class TripRepository {
// Mettre à jour un voyage // Mettre à jour un voyage
Future<void> updateTrip(String tripId, Trip trip) async { Future<void> updateTrip(String tripId, Trip trip) async {
try { try {
print('📝 Mise à jour du voyage: $tripId');
final tripData = trip.toMap(); final tripData = trip.toMap();
// Mettre à jour le timestamp de modification // Mettre à jour le timestamp de modification
tripData['updatedAt'] = Timestamp.now(); tripData['updatedAt'] = Timestamp.now();
await _tripsCollection.doc(tripId).update(tripData); await _tripsCollection.doc(tripId).update(tripData);
print('✅ Voyage $tripId mis à jour');
} catch (e) { } catch (e) {
print('❌ Erreur mise à jour voyage: $e');
throw Exception('Erreur lors de la mise à jour du voyage: $e'); throw Exception('Erreur lors de la mise à jour du voyage: $e');
} }
} }
@@ -95,13 +74,8 @@ class TripRepository {
// Supprimer un voyage // Supprimer un voyage
Future<void> deleteTrip(String tripId) async { Future<void> deleteTrip(String tripId) async {
try { try {
print('🗑️ Suppression du voyage: $tripId');
await _tripsCollection.doc(tripId).delete(); await _tripsCollection.doc(tripId).delete();
print('✅ Voyage $tripId supprimé');
} catch (e) { } catch (e) {
print('❌ Erreur suppression voyage: $e');
throw Exception('Erreur lors de la suppression du voyage: $e'); throw Exception('Erreur lors de la suppression du voyage: $e');
} }
} }

View File

@@ -30,6 +30,20 @@ class BalanceService {
} }
} }
Future<List<Settlement>> calculateOptimalSettlements(String groupId) async {
try {
final expenses = await _expenseRepository
.getExpensesStream(groupId)
.first;
final userBalances = calculateUserBalances(expenses);
return optimizeSettlements(userBalances);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur calcul settlements: $e');
rethrow;
}
}
/// Stream de la balance en temps réel /// Stream de la balance en temps réel
Stream<GroupBalance> getGroupBalanceStream(String groupId) { Stream<GroupBalance> getGroupBalanceStream(String groupId) {
return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async { return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async {