diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51f8c36..6a9e36c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -62,7 +62,7 @@ android:value="2" /> + android:value="AIzaSyAON_ol0Jr34tKbETvdDK9JCQdKNawxBeQ"/> diff --git a/lib/components/account/add_expense_dialog.dart b/lib/components/account/add_expense_dialog.dart index fc3475a..1df61d7 100644 --- a/lib/components/account/add_expense_dialog.dart +++ b/lib/components/account/add_expense_dialog.dart @@ -85,6 +85,18 @@ class AddExpenseDialog extends StatefulWidget { /// The expense to edit (null for new expense). final Expense? expenseToEdit; + /// Optional initial category for a new expense. + final ExpenseCategory? initialCategory; + + /// Optional initial amount for a new expense. + final double? initialAmount; + + /// Optional initial splits (userId -> amount) for a new expense. + final Map? initialSplits; + + /// Optional initial description for a new expense. + final String? initialDescription; + /// Creates an AddExpenseDialog. /// /// [group] is the group for the expense. @@ -95,6 +107,10 @@ class AddExpenseDialog extends StatefulWidget { required this.group, required this.currentUser, this.expenseToEdit, + this.initialCategory, + this.initialAmount, + this.initialSplits, + this.initialDescription, }); @override @@ -146,7 +162,10 @@ class _AddExpenseDialogState extends State { super.initState(); // Initialize form fields and splits based on whether editing or creating _selectedDate = widget.expenseToEdit?.date ?? DateTime.now(); - _selectedCategory = widget.expenseToEdit?.category ?? ExpenseCategory.other; + _selectedCategory = + widget.expenseToEdit?.category ?? + widget.initialCategory ?? + ExpenseCategory.other; _selectedCurrency = widget.expenseToEdit?.currency ?? ExpenseCurrency.eur; _paidById = widget.expenseToEdit?.paidById ?? widget.currentUser.id; @@ -159,9 +178,32 @@ class _AddExpenseDialogState extends State { } _splitEqually = false; } else { - // Creating: initialize splits for all group members - for (final member in widget.group.members) { - _splits[member.userId] = 0; + // Creating: initialize splits + if (widget.initialDescription != null) { + _descriptionController.text = widget.initialDescription!; + } + + if (widget.initialAmount != null) { + _amountController.text = widget.initialAmount.toString(); + } + + if (widget.initialSplits != null) { + _splits.addAll(widget.initialSplits!); + // Fill remaining members with 0 if not in initialSplits + for (final member in widget.group.members) { + if (!_splits.containsKey(member.userId)) { + _splits[member.userId] = 0; + } else { + // If we have specific splits, we probably aren't splitting equally by default logic + // unless we want to force it. For reimbursement, we likely set exact amounts. + _splitEqually = false; + } + } + } else { + // Default behavior: initialize splits for all group members + for (final member in widget.group.members) { + _splits[member.userId] = 0; + } } } } diff --git a/lib/components/account/balances_tab.dart b/lib/components/account/balances_tab.dart index dba1e63..66d94ec 100644 --- a/lib/components/account/balances_tab.dart +++ b/lib/components/account/balances_tab.dart @@ -6,7 +6,13 @@ library; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../blocs/user/user_bloc.dart'; +import '../../blocs/user/user_state.dart' as user_state; +import '../../models/expense.dart'; +import '../../models/group.dart'; import '../../models/user_balance.dart'; +import 'add_expense_dialog.dart'; /// A stateless widget that displays a list of user balances in a group. /// @@ -18,21 +24,19 @@ class BalancesTab extends StatelessWidget { /// The list of user balances to display. final List balances; + /// The group associated with these balances. + final Group group; + /// Creates a `BalancesTab` widget. /// /// The [balances] parameter must not be null. - const BalancesTab({ - super.key, - required this.balances, - }); + const BalancesTab({super.key, required this.balances, required this.group}); @override Widget build(BuildContext context) { // Check if the balances list is empty and display a placeholder message if true. if (balances.isEmpty) { - return const Center( - child: Text('Aucune balance à afficher'), - ); + return const Center(child: Text('Aucune balance à afficher')); } // Render the list of balances as a scrollable list. @@ -79,81 +83,149 @@ class BalancesTab extends StatelessWidget { margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), - child: Row( + child: Column( children: [ - // Display the user's initial in a circular avatar. - CircleAvatar( - radius: 24, - backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], - child: Text( - balance.userName.isNotEmpty - ? balance.userName[0].toUpperCase() - : '?', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 16), - // Display the user's name and financial details. - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // User's name. - Text( - balance.userName, + Row( + children: [ + // Display the user's initial in a circular avatar. + CircleAvatar( + radius: 24, + backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], + child: Text( + balance.userName.isNotEmpty + ? balance.userName[0].toUpperCase() + : '?', style: const TextStyle( - fontSize: 16, + fontSize: 20, fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 4), - // User's total paid and owed amounts. - Text( - 'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)} €', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ), - // Display the user's balance status and amount. - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - children: [ - // Icon indicating the balance status. - Icon(balanceIcon, size: 16, color: balanceColor), - const SizedBox(width: 4), - // User's absolute balance amount. - Text( - '${balance.absoluteBalance.toStringAsFixed(2)} €', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: balanceColor, + ), + const SizedBox(width: 16), + // Display the user's name and financial details. + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User's name. + Text( + balance.userName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), + const SizedBox(height: 4), + // User's total paid and owed amounts. + Text( + 'Payé: ${balance.totalPaid.toStringAsFixed(2)} € • Doit: ${balance.totalOwed.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ), + // Display the user's balance status and amount. + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + // Icon indicating the balance status. + Icon(balanceIcon, size: 16, color: balanceColor), + const SizedBox(width: 4), + // User's absolute balance amount. + Text( + '${balance.absoluteBalance.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: balanceColor, + ), + ), + ], + ), + // Text indicating the balance status (e.g., "À recevoir"). + Text( + balanceText, + style: TextStyle(fontSize: 12, color: balanceColor), ), ], ), - // Text indicating the balance status (e.g., "À recevoir"). - Text( - balanceText, - style: TextStyle( - fontSize: 12, - color: balanceColor, - ), - ), ], ), + // "Rembourser" button (Only show if this user is owed money and current user is looking at list? + // Wait, this list shows balances of everyone. + // Requirement: "Il faut un bouton dans la page qui permet de régler l'argent qu'on doit à une certaine personne" + // So if I look at "Alice", and Alice "shouldReceive" (is green), it implies the group owes Alice. + // But does it mean *I* owe Alice? + // The BalancesTab shows the *Group's* balances. + // However, usually settlement is 1-on-1. The requirement says: "régler l'argent qu'on doit à une certaine personne". + // If the user displayed here 'shouldReceive' money, it means they are owed money. + // If I click 'Rembourser', it implies *I* am paying them. + // This button should probably be available if the user on the card is POSITIVE (shouldReceive) + // AND I am not that user. + if (balance.shouldReceive) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showReimbursementDialog(context, balance), + icon: const Icon(Icons.monetization_on_outlined), + label: Text('Rembourser ${balance.userName}'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.green, + side: const BorderSide(color: Colors.green), + ), + ), + ), + ], ], ), ), ); } + + void _showReimbursementDialog( + BuildContext context, + UserBalance payeeBalance, + ) { + final userState = context.read().state; + + if (userState is! user_state.UserLoaded) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Erreur: utilisateur non connecté')), + ); + return; + } + + final currentUser = userState.user; + + // Prevent reimbursing yourself + if (payeeBalance.userId == currentUser.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vous ne pouvez pas vous rembourser vous-même'), + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) => AddExpenseDialog( + group: group, + currentUser: currentUser, + initialCategory: ExpenseCategory.reimbursement, + initialDescription: 'Remboursement', + initialAmount: payeeBalance.absoluteBalance, + initialSplits: { + payeeBalance.userId: payeeBalance + .absoluteBalance, // The payee receives the full amount (as split) + }, + ), + ); + } } diff --git a/lib/components/account/expenses_tab.dart b/lib/components/account/expenses_tab.dart index 4598e94..bc4c3fa 100644 --- a/lib/components/account/expenses_tab.dart +++ b/lib/components/account/expenses_tab.dart @@ -194,6 +194,8 @@ class ExpensesTab extends StatelessWidget { return Colors.teal; case ExpenseCategory.other: return Colors.grey; + case ExpenseCategory.reimbursement: + return Colors.green; } } diff --git a/lib/components/account/group_expenses_page.dart b/lib/components/account/group_expenses_page.dart index 2f986d3..8a1f361 100644 --- a/lib/components/account/group_expenses_page.dart +++ b/lib/components/account/group_expenses_page.dart @@ -80,14 +80,7 @@ class _GroupExpensesPageState extends State icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), - actions: [ - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: () { - _showFilterDialog(); - }, - ), - ], + actions: [], ), body: MultiBlocListener( listeners: [ @@ -193,7 +186,10 @@ class _GroupExpensesPageState extends State if (state is BalanceLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is GroupBalancesLoaded) { - return BalancesTab(balances: state.balances); + return BalancesTab( + balances: state.balances, + group: widget.group, + ); } else if (state is BalanceError) { return _buildErrorState('Erreur: ${state.message}'); } @@ -390,96 +386,4 @@ class _GroupExpensesPageState extends State ErrorService().showError(message: 'Erreur: utilisateur non connecté'); } } - - void _showFilterDialog() { - showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - title: const Text('Filtrer les dépenses'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - // ignore: deprecated_member_use - value: _selectedCategory, - decoration: const InputDecoration( - labelText: 'Catégorie', - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Toutes'), - ), - ...ExpenseCategory.values.map((category) { - return DropdownMenuItem( - value: category, - child: Text(category.displayName), - ); - }), - ], - onChanged: (value) { - setState(() => _selectedCategory = value); - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - // ignore: deprecated_member_use - value: _selectedPayerId, - decoration: const InputDecoration( - labelText: 'Payé par', - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Tous'), - ), - ...widget.group.members.map((member) { - return DropdownMenuItem( - value: member.userId, - child: Text(member.firstName), - ); - }), - ], - onChanged: (value) { - setState(() => _selectedPayerId = value); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - setState(() { - _selectedCategory = null; - _selectedPayerId = null; - }); - // Also update parent state - this.setState(() { - _selectedCategory = null; - _selectedPayerId = null; - }); - Navigator.pop(context); - }, - child: const Text('Réinitialiser'), - ), - ElevatedButton( - onPressed: () { - // Update parent state - this.setState(() {}); - Navigator.pop(context); - }, - child: const Text('Appliquer'), - ), - ], - ); - }, - ); - }, - ); - } } diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index c82f4fd..64d127a 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -26,6 +26,8 @@ import 'package:travel_mate/blocs/activity/activity_state.dart'; import 'package:travel_mate/blocs/balance/balance_bloc.dart'; import 'package:travel_mate/blocs/balance/balance_event.dart'; import 'package:travel_mate/blocs/balance/balance_state.dart'; +import 'package:travel_mate/blocs/group/group_bloc.dart'; +import 'package:travel_mate/blocs/group/group_event.dart'; import 'package:travel_mate/blocs/user/user_bloc.dart'; import 'package:travel_mate/blocs/user/user_state.dart' as user_state; @@ -641,6 +643,20 @@ class _ShowTripDetailsContentState extends State { ), const Divider(), ], + if (!isCreator) + ListTile( + leading: Icon(Icons.exit_to_app, color: Colors.red[400]), + title: Text( + 'Quitter le voyage', + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.red[400], + ), + ), + onTap: () { + Navigator.pop(context); + _handleLeaveTrip(currentUser); + }, + ), ListTile( leading: Icon( Icons.share, @@ -682,6 +698,91 @@ class _ShowTripDetailsContentState extends State { ); } + void _handleLeaveTrip(user_state.UserModel? currentUser) { + if (currentUser == null || _group == null) return; + + // Vérifier les dettes + final balanceState = context.read().state; + if (balanceState is GroupBalancesLoaded) { + final myBalance = balanceState.balances.firstWhere( + (b) => b.userId == currentUser.id, + orElse: () => const UserBalance( + userId: '', + userName: '', + totalPaid: 0, + totalOwed: 0, + balance: 0, + ), + ); + + // Tolérance pour les arrondis (0.01€) + if (myBalance.balance.abs() > 0.01) { + _errorService.showError( + message: + 'Vous devez régler vos dettes (ou récupérer votre argent) avant de quitter le voyage. Solde: ${myBalance.formattedBalance}', + ); + return; + } + + _confirmLeaveTrip(currentUser.id); + } else { + // Si les balances ne sont pas chargées, on essaie de les charger et on demande de rééssayer + context.read().add(LoadGroupBalances(_group!.id)); + _errorService.showError( + message: + 'Impossible de vérifier votre solde. Veuillez réessayer dans un instant.', + ); + } + } + + void _confirmLeaveTrip(String userId) { + final theme = Theme.of(context); + + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, + title: Text( + 'Quitter le voyage', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + content: Text( + 'Êtes-vous sûr de vouloir quitter ce voyage ? Vous ne pourrez plus voir les détails ni les dépenses.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Annuler', + style: TextStyle(color: theme.colorScheme.primary), + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); // Fermer le dialog + + if (_group != null) { + context.read().add( + RemoveMemberFromGroup(_group!.id, userId), + ); + + // Retourner à l'écran d'accueil + Navigator.pop(context); + } + }, + child: const Text('Quitter', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + void _confirmDeleteTrip() { final theme = Theme.of(context); diff --git a/lib/components/map/map_content.dart b/lib/components/map/map_content.dart index ceb5c82..93b400c 100644 --- a/lib/components/map/map_content.dart +++ b/lib/components/map/map_content.dart @@ -325,14 +325,14 @@ class _MapContentState extends State { Future _createCustomMarkerIcon() async { final pictureRecorder = ui.PictureRecorder(); final canvas = Canvas(pictureRecorder); - const size = 120.0; + const size = 80.0; // Dessiner l'icône person_pin_circle en bleu final iconPainter = TextPainter(textDirection: TextDirection.ltr); iconPainter.text = TextSpan( text: String.fromCharCode(Icons.person_pin_circle.codePoint), style: TextStyle( - fontSize: 100, + fontSize: 70, fontFamily: Icons.person_pin_circle.fontFamily, color: Colors.blue[700], ), diff --git a/lib/main.dart b/lib/main.dart index 5f348bf..9f34097 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:travel_mate/blocs/balance/balance_bloc.dart'; import 'package:travel_mate/blocs/expense/expense_bloc.dart'; +import 'package:google_maps_flutter_android/google_maps_flutter_android.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:travel_mate/blocs/message/message_bloc.dart'; import 'package:travel_mate/blocs/activity/activity_bloc.dart'; import 'package:travel_mate/firebase_options.dart'; @@ -54,6 +56,13 @@ void main() async { await NotificationService().initialize(); + // Requirements for Google Maps on Android (Hybrid Composition) + final GoogleMapsFlutterPlatform mapsImplementation = + GoogleMapsFlutterPlatform.instance; + if (mapsImplementation is GoogleMapsFlutterAndroid) { + mapsImplementation.useAndroidViewSurface = true; + } + runApp(const MyApp()); } diff --git a/lib/models/expense.dart b/lib/models/expense.dart index d45a799..9742d93 100644 --- a/lib/models/expense.dart +++ b/lib/models/expense.dart @@ -45,7 +45,10 @@ enum ExpenseCategory { shopping('Shopping', Icons.shopping_bag), /// Other miscellaneous expenses - other('Other', Icons.category); + other('Other', Icons.category), + + /// Reimbursement for settling debts + reimbursement('Remboursement', Icons.monetization_on); const ExpenseCategory(this.displayName, this.icon);