diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d4a4263..7b0e425 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ { final ErrorService _errorService; /// Constructor for BalanceBloc. - /// + /// /// Initializes the bloc with required repositories and optional services. /// Sets up event handlers for balance-related operations. - /// + /// /// Args: /// [balanceRepository]: Repository for balance data operations /// [expenseRepository]: Repository for expense data operations @@ -61,7 +62,12 @@ class BalanceBloc extends Bloc { BalanceService? balanceService, ErrorService? errorService, }) : _balanceRepository = balanceRepository, - _balanceService = balanceService ?? BalanceService(balanceRepository: balanceRepository, expenseRepository: expenseRepository), + _balanceService = + balanceService ?? + BalanceService( + balanceRepository: balanceRepository, + expenseRepository: expenseRepository, + ), _errorService = errorService ?? ErrorService(), super(BalanceInitial()) { on(_onLoadGroupBalance); @@ -70,11 +76,11 @@ class BalanceBloc extends Bloc { } /// Handles [LoadGroupBalances] events. - /// + /// /// Loads and calculates user balances for a specific group along with /// optimal settlement recommendations. This provides a complete overview /// of who owes money to whom and the most efficient payment strategy. - /// + /// /// Args: /// [event]: The LoadGroupBalances event containing the group ID /// [emit]: State emitter function @@ -83,18 +89,22 @@ class BalanceBloc extends Bloc { Emitter emit, ) async { try { - emit(BalanceLoading()); - + // Emit empty state initially to avoid infinite spinner + emit(const GroupBalancesLoaded(balances: [], settlements: [])); + // Calculate group user balances - final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId); - + final userBalances = await _balanceRepository.calculateGroupUserBalances( + event.groupId, + ); + // Calculate optimal settlements - final settlements = await _balanceService.calculateOptimalSettlements(event.groupId); - - emit(GroupBalancesLoaded( - balances: userBalances, - settlements: settlements, - )); + final settlements = await _balanceService.calculateOptimalSettlements( + event.groupId, + ); + + emit( + GroupBalancesLoaded(balances: userBalances, settlements: settlements), + ); } catch (e) { _errorService.logError('BalanceBloc', 'Error loading balance: $e'); emit(BalanceError(e.toString())); @@ -102,11 +112,11 @@ class BalanceBloc extends Bloc { } /// Handles [RefreshBalance] events. - /// + /// /// Refreshes the balance data for a group while trying to maintain the current /// state when possible to provide a smoother user experience. Only shows loading /// state if there's no existing balance data. - /// + /// /// Args: /// [event]: The RefreshBalance event containing the group ID /// [emit]: State emitter function @@ -119,17 +129,20 @@ class BalanceBloc extends Bloc { if (state is! GroupBalancesLoaded) { emit(BalanceLoading()); } - + // Calculate group user balances - final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId); - + final userBalances = await _balanceRepository.calculateGroupUserBalances( + event.groupId, + ); + // Calculate optimal settlements - final settlements = await _balanceService.calculateOptimalSettlements(event.groupId); - - emit(GroupBalancesLoaded( - balances: userBalances, - settlements: settlements, - )); + final settlements = await _balanceService.calculateOptimalSettlements( + event.groupId, + ); + + emit( + GroupBalancesLoaded(balances: userBalances, settlements: settlements), + ); } catch (e) { _errorService.logError('BalanceBloc', 'Error refreshing balance: $e'); emit(BalanceError(e.toString())); @@ -137,11 +150,11 @@ class BalanceBloc extends Bloc { } /// Handles [MarkSettlementAsCompleted] events. - /// + /// /// Records a settlement transaction between two users, marking that /// a debt has been paid. This updates the balance calculations and /// automatically refreshes the group balance data to reflect the change. - /// + /// /// Args: /// [event]: The MarkSettlementAsCompleted event containing settlement details /// [emit]: State emitter function @@ -156,9 +169,9 @@ class BalanceBloc extends Bloc { toUserId: event.toUserId, amount: event.amount, ); - + emit(const BalanceOperationSuccess('Settlement marked as completed')); - + // Reload balance after settlement add(RefreshBalance(event.groupId)); } catch (e) { @@ -166,4 +179,4 @@ class BalanceBloc extends Bloc { emit(BalanceError(e.toString())); } } -} \ No newline at end of file +} diff --git a/lib/blocs/expense/expense_bloc.dart b/lib/blocs/expense/expense_bloc.dart index 9670c2e..0b48977 100644 --- a/lib/blocs/expense/expense_bloc.dart +++ b/lib/blocs/expense/expense_bloc.dart @@ -7,7 +7,7 @@ import 'expense_event.dart'; import 'expense_state.dart'; /// BLoC for managing expense operations and state. -/// +/// /// This BLoC handles expense-related operations including loading expenses, /// creating new expenses, updating existing ones, deleting expenses, and /// managing expense splits. It coordinates with the expense repository and @@ -15,18 +15,18 @@ import 'expense_state.dart'; class ExpenseBloc extends Bloc { /// Repository for expense data operations. final ExpenseRepository _expenseRepository; - + /// Service for expense business logic and validation. final ExpenseService _expenseService; - + /// Service for error handling and logging. final ErrorService _errorService; - + /// Subscription to the expenses stream for real-time updates. StreamSubscription? _expensesSubscription; /// Creates a new [ExpenseBloc] with required dependencies. - /// + /// /// [expenseRepository] is required for data operations. /// [expenseService] and [errorService] have default implementations if not provided. ExpenseBloc({ @@ -34,10 +34,11 @@ class ExpenseBloc extends Bloc { ExpenseService? expenseService, ErrorService? errorService, }) : _expenseRepository = expenseRepository, - _expenseService = expenseService ?? ExpenseService(expenseRepository: expenseRepository), + _expenseService = + expenseService ?? + ExpenseService(expenseRepository: expenseRepository), _errorService = errorService ?? ErrorService(), super(ExpenseInitial()) { - on(_onLoadExpensesByGroup); on(_onExpensesUpdated); on(_onCreateExpense); @@ -48,7 +49,7 @@ class ExpenseBloc extends Bloc { } /// Handles [LoadExpensesByGroup] events. - /// + /// /// Sets up a stream subscription to receive real-time updates for expenses /// in the specified group. Cancels any existing subscription before creating a new one. Future _onLoadExpensesByGroup( @@ -56,15 +57,18 @@ class ExpenseBloc extends Bloc { Emitter emit, ) async { try { - emit(ExpenseLoading()); - + // Emit empty state initially to avoid infinite spinner + // The stream will update with actual data when available + emit(const ExpensesLoaded(expenses: [])); + await _expensesSubscription?.cancel(); - + _expensesSubscription = _expenseRepository .getExpensesStream(event.groupId) .listen( (expenses) => add(ExpensesUpdated(expenses)), - onError: (error) => add(ExpensesUpdated([], error: error.toString())), + onError: (error) => + add(ExpensesUpdated([], error: error.toString())), ); } catch (e) { _errorService.logError('ExpenseBloc', 'Error loading expenses: $e'); @@ -73,10 +77,10 @@ class ExpenseBloc extends Bloc { } /// Handles [ExpensesUpdated] events. - /// + /// /// Processes real-time updates from the expense stream, either emitting /// the updated expense list or an error state if the stream encountered an error. - /// + /// /// Args: /// [event]: The ExpensesUpdated event containing expenses or error information /// [emit]: State emitter function @@ -92,11 +96,11 @@ class ExpenseBloc extends Bloc { } /// Handles [CreateExpense] events. - /// + /// /// Creates a new expense with validation and optional receipt image upload. /// Uses the expense service to handle business logic and validation, /// including currency conversion and split calculations. - /// + /// /// Args: /// [event]: The CreateExpense event containing expense data and optional receipt /// [emit]: State emitter function @@ -105,7 +109,10 @@ class ExpenseBloc extends Bloc { Emitter emit, ) async { try { - await _expenseService.createExpenseWithValidation(event.expense, event.receiptImage); + await _expenseService.createExpenseWithValidation( + event.expense, + event.receiptImage, + ); emit(const ExpenseOperationSuccess('Expense created successfully')); } catch (e) { _errorService.logError('ExpenseBloc', 'Error creating expense: $e'); @@ -114,11 +121,11 @@ class ExpenseBloc extends Bloc { } /// Handles [UpdateExpense] events. - /// + /// /// Updates an existing expense with validation and optional new receipt image. /// Uses the expense service to handle business logic, validation, and /// recalculation of splits if expense details change. - /// + /// /// Args: /// [event]: The UpdateExpense event containing updated expense data and optional new receipt /// [emit]: State emitter function @@ -127,7 +134,10 @@ class ExpenseBloc extends Bloc { Emitter emit, ) async { try { - await _expenseService.updateExpenseWithValidation(event.expense, event.newReceiptImage); + await _expenseService.updateExpenseWithValidation( + event.expense, + event.newReceiptImage, + ); emit(const ExpenseOperationSuccess('Expense updated successfully')); } catch (e) { _errorService.logError('ExpenseBloc', 'Error updating expense: $e'); @@ -136,10 +146,10 @@ class ExpenseBloc extends Bloc { } /// Handles [DeleteExpense] events. - /// + /// /// Permanently deletes an expense from the database. This action /// cannot be undone and will affect group balance calculations. - /// + /// /// Args: /// [event]: The DeleteExpense event containing the expense ID to delete /// [emit]: State emitter function @@ -157,11 +167,11 @@ class ExpenseBloc extends Bloc { } /// Handles [MarkSplitAsPaid] events. - /// + /// /// Marks a user's portion of an expense split as paid, updating the /// expense's split information and affecting balance calculations. /// This helps track who has settled their portion of shared expenses. - /// + /// /// Args: /// [event]: The MarkSplitAsPaid event containing expense ID and user ID /// [emit]: State emitter function @@ -179,11 +189,11 @@ class ExpenseBloc extends Bloc { } /// Handles [ArchiveExpense] events. - /// + /// /// Archives an expense, moving it out of the active expense list /// while preserving it for historical records and audit purposes. /// Archived expenses are not included in current balance calculations. - /// + /// /// Args: /// [event]: The ArchiveExpense event containing the expense ID to archive /// [emit]: State emitter function @@ -201,7 +211,7 @@ class ExpenseBloc extends Bloc { } /// Cleans up resources when the bloc is closed. - /// + /// /// Cancels the expense stream subscription to prevent memory leaks /// and ensure proper disposal of resources. @override @@ -209,4 +219,4 @@ class ExpenseBloc extends Bloc { _expensesSubscription?.cancel(); return super.close(); } -} \ No newline at end of file +} diff --git a/lib/components/account/add_expense_dialog.dart b/lib/components/account/add_expense_dialog.dart index f50c6fc..0fe0699 100644 --- a/lib/components/account/add_expense_dialog.dart +++ b/lib/components/account/add_expense_dialog.dart @@ -56,6 +56,7 @@ /// - Group /// - ExpenseBloc library; + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -76,8 +77,10 @@ import '../../models/expense.dart'; class AddExpenseDialog extends StatefulWidget { /// The group to which the expense belongs. final Group group; + /// The user creating or editing the expense. final user_state.UserModel currentUser; + /// The expense to edit (null for new expense). final Expense? expenseToEdit; @@ -103,27 +106,40 @@ class AddExpenseDialog extends StatefulWidget { class _AddExpenseDialogState extends State { /// Form key for validating the expense form. final _formKey = GlobalKey(); + /// Controller for the expense description field. final _descriptionController = TextEditingController(); + /// Controller for the expense amount field. final _amountController = TextEditingController(); + /// The selected date for the expense. late DateTime _selectedDate; + /// The selected category for the expense. late ExpenseCategory _selectedCategory; + /// The selected currency for the expense. late ExpenseCurrency _selectedCurrency; + /// The user ID of the payer. late String _paidById; + /// Map of userId to split amount for each participant. final Map _splits = {}; + /// The selected receipt image file, if any. File? _receiptImage; + /// Whether the dialog is currently submitting data. bool _isLoading = false; + /// Whether the expense is split equally among participants. bool _splitEqually = true; + /// Whether the existing receipt has been removed. + bool _receiptRemoved = false; + @override void initState() { super.initState(); @@ -172,7 +188,7 @@ class _AddExpenseDialogState extends State { if (pickedFile != null) { final file = File(pickedFile.path); final fileSize = await file.length(); - + if (fileSize > 5 * 1024 * 1024) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -199,11 +215,11 @@ class _AddExpenseDialogState extends State { final amount = double.tryParse(_amountController.text) ?? 0; final selectedMembers = _splits.entries.where((e) => e.value >= 0).toList(); - + if (selectedMembers.isEmpty) return; final splitAmount = amount / selectedMembers.length; - + setState(() { for (final entry in selectedMembers) { _splits[entry.key] = splitAmount; @@ -221,17 +237,14 @@ class _AddExpenseDialogState extends State { if (!_formKey.currentState!.validate()) return; final amount = double.parse(_amountController.text); - final selectedSplits = _splits.entries - .where((e) => e.value > 0) - .map((e) { - final member = widget.group.members.firstWhere((m) => m.userId == e.key); - return ExpenseSplit( - userId: e.key, - userName: member.firstName, - amount: e.value, - ); - }) - .toList(); + final selectedSplits = _splits.entries.where((e) => e.value > 0).map((e) { + final member = widget.group.members.firstWhere((m) => m.userId == e.key); + return ExpenseSplit( + userId: e.key, + userName: member.firstName, + amount: e.value, + ); + }).toList(); if (selectedSplits.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -248,11 +261,15 @@ class _AddExpenseDialogState extends State { try { // Convertir en EUR final amountInEur = context.read().state is ExpensesLoaded - ? (context.read().state as ExpensesLoaded) - .exchangeRates[_selectedCurrency]! * amount + ? ((context.read().state as ExpensesLoaded) + .exchangeRates[_selectedCurrency.code] ?? + 1.0) * + amount : amount; - final payer = widget.group.members.firstWhere((m) => m.userId == _paidById); + final payer = widget.group.members.firstWhere( + (m) => m.userId == _paidById, + ); final expense = Expense( id: widget.expenseToEdit?.id ?? '', @@ -266,29 +283,29 @@ class _AddExpenseDialogState extends State { paidByName: payer.firstName, splits: selectedSplits, date: _selectedDate, - receiptUrl: widget.expenseToEdit?.receiptUrl, + receiptUrl: _receiptRemoved ? null : widget.expenseToEdit?.receiptUrl, createdAt: widget.expenseToEdit?.createdAt ?? DateTime.now(), ); if (widget.expenseToEdit == null) { - context.read().add(CreateExpense( - expense: expense, - receiptImage: _receiptImage, - )); + context.read().add( + CreateExpense(expense: expense, receiptImage: _receiptImage), + ); } else { - context.read().add(UpdateExpense( - expense: expense, - newReceiptImage: _receiptImage, - )); + context.read().add( + UpdateExpense(expense: expense, newReceiptImage: _receiptImage), + ); } if (mounted) { Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(widget.expenseToEdit == null - ? 'Dépense ajoutée' - : 'Dépense modifiée'), + content: Text( + widget.expenseToEdit == null + ? 'Dépense ajoutée' + : 'Dépense modifiée', + ), backgroundColor: Colors.green, ), ); @@ -296,10 +313,7 @@ class _AddExpenseDialogState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), ); } } finally { @@ -314,284 +328,425 @@ class _AddExpenseDialogState extends State { /// Returns a Dialog widget containing the expense form. @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: isDark ? theme.scaffoldBackgroundColor : Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Container( - constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), - child: Scaffold( - appBar: AppBar( - title: Text(widget.expenseToEdit == null - ? 'Nouvelle dépense' - : 'Modifier la dépense'), - automaticallyImplyLeading: false, - actions: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 800), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 16, 16), + child: Row( + children: [ + Expanded( + child: Text( + widget.expenseToEdit == null + ? 'Nouvelle dépense' + : 'Modifier la dépense', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), - ], - ), - body: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Description - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description', - hintText: 'Ex: Restaurant, Essence...', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.description), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Veuillez entrer une description'; - } - return null; - }, - ), - const SizedBox(height: 16), + ), + const Divider(height: 1), - // Montant et devise - Row( + // Form Content + Expanded( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), children: [ - Expanded( - flex: 2, - child: TextFormField( - controller: _amountController, - decoration: const InputDecoration( - labelText: 'Montant', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.euro), + // Description + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: 'Description', + hintText: 'Ex: Restaurant, Essence...', + prefixIcon: const Icon(Icons.description_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (_) => _calculateSplits(), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Requis'; - } - if (double.tryParse(value) == null || double.parse(value) <= 0) { - return 'Montant invalide'; - } - return null; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: DropdownButtonFormField( - initialValue: _selectedCurrency, - decoration: const InputDecoration( - labelText: 'Devise', - border: OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, ), - items: ExpenseCurrency.values.map((currency) { - return DropdownMenuItem( - value: currency, - child: Text(currency.code), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() => _selectedCurrency = value); - } - }, ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Requis'; + } + return null; + }, ), - ], - ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Catégorie - DropdownButtonFormField( - initialValue: _selectedCategory, - decoration: const InputDecoration( - labelText: 'Catégorie', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: ExpenseCategory.values.map((category) { - return DropdownMenuItem( - value: category, - child: Row( - children: [ - Icon(category.icon, size: 20), - const SizedBox(width: 8), - Text(category.displayName), - ], - ), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() => _selectedCategory = value); - } - }, - ), - const SizedBox(height: 16), - - // Date - ListTile( - leading: const Icon(Icons.calendar_today), - title: const Text('Date'), - subtitle: Text(DateFormat('dd/MM/yyyy').format(_selectedDate)), - shape: RoundedRectangleBorder( - side: BorderSide(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(4), - ), - onTap: () async { - final date = await showDatePicker( - context: context, - initialDate: _selectedDate, - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (date != null) { - setState(() => _selectedDate = date); - } - }, - ), - const SizedBox(height: 16), - - // Payé par - DropdownButtonFormField( - initialValue: _paidById, - decoration: const InputDecoration( - labelText: 'Payé par', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person), - ), - items: widget.group.members.map((member) { - return DropdownMenuItem( - value: member.userId, - child: Text(member.firstName), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() => _paidById = value); - } - }, - ), - const SizedBox(height: 16), - - // Division - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( + // Montant et Devise + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Text( - 'Division', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + Expanded( + flex: 2, + child: TextFormField( + controller: _amountController, + decoration: InputDecoration( + labelText: 'Montant', + prefixIcon: const Icon(Icons.euro_symbol), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, ), ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) => _calculateSplits(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Requis'; + } + if (double.tryParse(value) == null || + double.parse(value) <= 0) { + return 'Invalide'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: _selectedCurrency, + decoration: InputDecoration( + labelText: 'Devise', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + ), + items: ExpenseCurrency.values.map((currency) { + return DropdownMenuItem( + value: currency, + child: Text(currency.code), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedCurrency = value); + } + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Catégorie + DropdownButtonFormField( + initialValue: _selectedCategory, + decoration: InputDecoration( + labelText: 'Catégorie', + prefixIcon: Icon(_selectedCategory.icon), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + items: ExpenseCategory.values.map((category) { + return DropdownMenuItem( + value: category, + child: Text(category.displayName), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedCategory = value); + } + }, + ), + const SizedBox(height: 16), + + // Date + InkWell( + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + if (date != null) setState(() => _selectedDate = date); + }, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Date', + prefixIcon: const Icon(Icons.calendar_today_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + child: Text( + DateFormat('dd/MM/yyyy').format(_selectedDate), + style: theme.textTheme.bodyLarge, + ), + ), + ), + const SizedBox(height: 16), + + // Payé par + DropdownButtonFormField( + initialValue: _paidById, + decoration: InputDecoration( + labelText: 'Payé par', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + items: widget.group.members.map((member) { + return DropdownMenuItem( + value: member.userId, + child: Text(member.firstName), + ); + }).toList(), + onChanged: (value) { + if (value != null) setState(() => _paidById = value); + }, + ), + const SizedBox(height: 24), + + // Division Section + Container( + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text( + 'Division', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + 'Égale', + style: TextStyle( + color: isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + ), + const SizedBox(width: 8), + Switch( + value: _splitEqually, + onChanged: (value) { + setState(() { + _splitEqually = value; + if (value) _calculateSplits(); + }); + }, + activeThumbColor: theme.colorScheme.primary, + ), + ], + ), + const Divider(), + ...widget.group.members.map((member) { + final isSelected = + _splits.containsKey(member.userId) && + _splits[member.userId]! >= 0; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + member.firstName, + style: const TextStyle(fontSize: 16), + ), + ), + if (!_splitEqually && isSelected) + SizedBox( + width: 100, + child: TextFormField( + initialValue: _splits[member.userId] + ?.toStringAsFixed(2), + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder(), + suffixText: '€', + ), + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (value) { + final amount = + double.tryParse(value) ?? 0; + setState( + () => + _splits[member.userId] = amount, + ); + }, + ), + ), + Checkbox( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _splits[member.userId] = 0; + if (_splitEqually) _calculateSplits(); + } else { + _splits[member.userId] = -1; + } + }); + }, + activeColor: theme.colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ); + }), + ], + ), + ), + const SizedBox(height: 16), + + // Reçu (Optional - keeping simple for now as per design focus) + if (_receiptImage != null || + (widget.expenseToEdit?.receiptUrl != null && + !_receiptRemoved)) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.receipt_long, color: Colors.green), + const SizedBox(width: 8), + const Text('Reçu joint'), const Spacer(), - Text(_splitEqually ? 'Égale' : 'Personnalisée'), - Switch( - value: _splitEqually, - onChanged: (value) { - setState(() { - _splitEqually = value; - if (value) _calculateSplits(); - }); - }, + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _receiptImage = null; + _receiptRemoved = true; + }), ), ], ), - const Divider(), - ...widget.group.members.map((member) { - final isSelected = _splits.containsKey(member.userId) && - _splits[member.userId]! >= 0; - - return CheckboxListTile( - title: Text(member.firstName), - subtitle: _splitEqually || !isSelected - ? null - : TextFormField( - initialValue: _splits[member.userId]?.toStringAsFixed(2), - decoration: const InputDecoration( - labelText: 'Montant', - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - onChanged: (value) { - final amount = double.tryParse(value) ?? 0; - setState(() => _splits[member.userId] = amount); - }, - ), - value: isSelected, - onChanged: (value) { - setState(() { - if (value == true) { - _splits[member.userId] = 0; - if (_splitEqually) _calculateSplits(); - } else { - _splits[member.userId] = -1; - } - }); - }, - ); - }), - ], - ), - ), - ), - const SizedBox(height: 16), - - // Reçu - ListTile( - leading: const Icon(Icons.receipt), - title: Text(_receiptImage != null || widget.expenseToEdit?.receiptUrl != null - ? 'Reçu ajouté' - : 'Ajouter un reçu'), - subtitle: _receiptImage != null - ? const Text('Nouveau reçu sélectionné') - : null, - trailing: IconButton( - icon: const Icon(Icons.add_photo_alternate), - onPressed: _pickImage, - ), - shape: RoundedRectangleBorder( - side: BorderSide(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 24), - - // Boutons - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isLoading ? null : () => Navigator.of(context).pop(), - child: const Text('Annuler'), + ) + else + OutlinedButton.icon( + onPressed: _pickImage, + icon: const Icon(Icons.camera_alt_outlined), + label: const Text('Ajouter un reçu'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: _isLoading ? null : _submit, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(widget.expenseToEdit == null ? 'Ajouter' : 'Modifier'), - ), - ), ], ), - ], + ), ), - ), + + // Bottom Button + Padding( + padding: const EdgeInsets.all(24), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + widget.expenseToEdit == null + ? 'Ajouter' + : 'Enregistrer', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], ), ), ); diff --git a/lib/components/account/expenses_tab.dart b/lib/components/account/expenses_tab.dart index 2270436..4598e94 100644 --- a/lib/components/account/expenses_tab.dart +++ b/lib/components/account/expenses_tab.dart @@ -7,11 +7,13 @@ import 'expense_detail_dialog.dart'; class ExpensesTab extends StatelessWidget { final List expenses; final Group group; + final String currentUserId; const ExpensesTab({ super.key, required this.expenses, required this.group, + required this.currentUserId, }); @override @@ -48,95 +50,157 @@ class ExpensesTab extends StatelessWidget { } Widget _buildExpenseCard(BuildContext context, Expense expense) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final dateFormat = DateFormat('dd/MM/yyyy'); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final dateFormat = DateFormat('dd/MM'); - return Card( + // Logique pour déterminer l'impact sur l'utilisateur + bool isPayer = expense.paidById == currentUserId; + double amountToDisplay = expense.amount; + bool isPositive = isPayer; + + // Si je suis le payeur, je suis en positif (on me doit de l'argent) + // Si je ne suis pas le payeur, je suis en négatif (je dois de l'argent) + // Note: Pour être précis, il faudrait calculer ma part exacte, mais pour l'instant + // on affiche le total avec la couleur indiquant si j'ai payé ou non. + + final amountColor = isPositive ? Colors.green : Colors.red; + final prefix = isPositive ? '+' : '-'; + + return Container( margin: const EdgeInsets.only(bottom: 12), - child: InkWell( - onTap: () => _showExpenseDetail(context, expense), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: isDark ? Colors.blue[900] : Colors.blue[100], - borderRadius: BorderRadius.circular(12), + decoration: BoxDecoration( + color: isDark ? theme.cardColor : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showExpenseDetail(context, expense), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Icone circulaire + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _getCategoryColor( + expense.category, + ).withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + expense.category.icon, + color: _getCategoryColor(expense.category), + size: 24, + ), ), - child: Icon( - expense.category.icon, - color: Colors.blue, + const SizedBox(width: 16), + + // Détails + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + expense.description, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + isPayer + ? 'Payé par vous' + : 'Payé par ${expense.paidByName}', + style: TextStyle( + fontSize: 13, + color: isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + ), + Text( + ' • ${dateFormat.format(expense.date)}', + style: TextStyle( + fontSize: 13, + color: isDark + ? Colors.grey[500] + : Colors.grey[500], + ), + ), + ], + ), + ], + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + // Montant + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - expense.description, - style: const TextStyle( + '$prefix${amountToDisplay.toStringAsFixed(2)} ${expense.currency.symbol}', + style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, + color: amountColor, ), ), - const SizedBox(height: 4), - Text( - 'Payé par ${expense.paidByName}', - style: TextStyle( - fontSize: 14, - color: isDark ? Colors.grey[400] : Colors.grey[600], + if (expense.currency != ExpenseCurrency.eur) + Text( + 'Total ${expense.amountInEur.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), ), - ), - Text( - dateFormat.format(expense.date), - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[500] : Colors.grey[500], - ), - ), ], ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - if (expense.currency != ExpenseCurrency.eur) - Text( - '${expense.amountInEur.toStringAsFixed(2)} €', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ], + ], + ), ), ), ), ); } + Color _getCategoryColor(ExpenseCategory category) { + switch (category) { + case ExpenseCategory.restaurant: + return Colors.orange; + case ExpenseCategory.transport: + return Colors.blue; + case ExpenseCategory.accommodation: + return Colors.purple; + case ExpenseCategory.entertainment: + return Colors.pink; + case ExpenseCategory.shopping: + return Colors.teal; + case ExpenseCategory.other: + return Colors.grey; + } + } + void _showExpenseDetail(BuildContext context, Expense expense) { showDialog( context: context, - builder: (context) => ExpenseDetailDialog( - expense: expense, - group: group, - ), + builder: (context) => ExpenseDetailDialog(expense: expense, group: group), ); } } diff --git a/lib/components/account/group_expenses_page.dart b/lib/components/account/group_expenses_page.dart index 71f8e42..9d84cc5 100644 --- a/lib/components/account/group_expenses_page.dart +++ b/lib/components/account/group_expenses_page.dart @@ -13,7 +13,8 @@ import '../../models/group.dart'; import 'add_expense_dialog.dart'; import 'balances_tab.dart'; import 'expenses_tab.dart'; -import 'settlements_tab.dart'; +import '../../models/user_balance.dart'; +import '../../models/expense.dart'; class GroupExpensesPage extends StatefulWidget { final Account account; @@ -31,13 +32,14 @@ class GroupExpensesPage extends StatefulWidget { class _GroupExpensesPageState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - + ExpenseCategory? _selectedCategory; + String? _selectedPayerId; + @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 2, vsync: this); _loadData(); } @@ -50,39 +52,41 @@ class _GroupExpensesPageState extends State void _loadData() { // Charger les dépenses du groupe context.read().add(LoadExpensesByGroup(widget.group.id)); - + // Charger les balances du groupe context.read().add(LoadGroupBalances(widget.group.id)); } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + return Scaffold( + backgroundColor: isDarkMode + ? theme.scaffoldBackgroundColor + : Colors.grey[50], 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', - ), - ], + title: const Text( + 'Dépenses du voyage', + style: TextStyle(fontWeight: FontWeight.bold), ), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: theme.colorScheme.onSurface, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + _showFilterDialog(); + }, + ), + ], ), body: MultiBlocListener( listeners: [ @@ -103,56 +107,107 @@ class _GroupExpensesPageState extends State backgroundColor: Colors.red, ), ); + } else if (state is ExpensesLoaded) { + // Rafraîchir les balances quand les dépenses changent (ex: via stream) + context.read().add( + RefreshBalance(widget.group.id), + ); } }, ), ], - child: TabBarView( - controller: _tabController, + child: Column( children: [ - // Onglet Balances + // Summary Card BlocBuilder( 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}'); + if (state is GroupBalancesLoaded) { + return _buildSummaryCard(state.balances, isDarkMode); } - return _buildEmptyState('Aucune balance disponible'); + return const SizedBox.shrink(); }, ), - - // Onglet Dépenses - BlocBuilder( - 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'); - }, + + // Tabs + Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor, width: 1), + ), + ), + child: TabBar( + controller: _tabController, + labelColor: theme.colorScheme.primary, + unselectedLabelColor: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + indicatorColor: theme.colorScheme.primary, + indicatorWeight: 3, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + tabs: const [ + Tab(text: 'Toutes les dépenses'), + Tab(text: 'Mes soldes'), + ], + ), ), - - // Onglet Règlements - BlocBuilder( - 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'); - }, + + // Tab View + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Onglet Dépenses + BlocBuilder( + builder: (context, state) { + if (state is ExpenseLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is ExpensesLoaded) { + final userState = context.read().state; + final currentUserId = userState is user_state.UserLoaded + ? userState.user.id + : ''; + + var filteredExpenses = state.expenses; + + if (_selectedCategory != null) { + filteredExpenses = filteredExpenses + .where((e) => e.category == _selectedCategory) + .toList(); + } + + if (_selectedPayerId != null) { + filteredExpenses = filteredExpenses + .where((e) => e.paidById == _selectedPayerId) + .toList(); + } + + return ExpensesTab( + expenses: filteredExpenses, + group: widget.group, + currentUserId: currentUserId, + ); + } else if (state is ExpenseError) { + return _buildErrorState('Erreur: ${state.message}'); + } + return _buildEmptyState('Aucune dépense trouvée'); + }, + ), + + // Onglet Balances (Combiné) + BlocBuilder( + 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: ${state.message}'); + } + return _buildEmptyState('Aucune balance disponible'); + }, + ), + ], + ), ), ], ), @@ -160,8 +215,109 @@ class _GroupExpensesPageState extends State floatingActionButton: FloatingActionButton( onPressed: _showAddExpenseDialog, heroTag: "add_expense_fab", + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + elevation: 4, + shape: const CircleBorder(), tooltip: 'Ajouter une dépense', - child: const Icon(Icons.add), + child: const Icon(Icons.add, size: 32), + ), + ); + } + + Widget _buildSummaryCard(List balances, bool isDarkMode) { + // Trouver la balance de l'utilisateur courant + final userState = context.read().state; + double myBalance = 0; + + if (userState is user_state.UserLoaded) { + final myBalanceObj = balances.firstWhere( + (b) => b.userId == userState.user.id, + orElse: () => const UserBalance( + userId: '', + userName: '', + totalPaid: 0, + totalOwed: 0, + balance: 0, + ), + ); + myBalance = myBalanceObj.balance; + } + + final isPositive = myBalance >= 0; + final color = isPositive ? Colors.green : Colors.red; + final amountStr = '${myBalance.abs().toStringAsFixed(2)} €'; + + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Votre solde total', + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + isPositive ? 'On vous doit ' : 'Vous devez ', + style: TextStyle( + color: color, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Text( + amountStr, + style: TextStyle( + color: color, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + isPositive + ? 'Vous êtes en positif sur ce voyage.' + : 'Vous êtes en négatif sur ce voyage.', + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 14, + ), + ), + const SizedBox(height: 12), + InkWell( + onTap: () { + _tabController.animateTo(1); // Aller à l'onglet Balances + }, + child: Text( + 'Voir le détail des soldes', + style: TextStyle( + color: Colors.blue[400], + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + ), + ], ), ); } @@ -171,11 +327,7 @@ class _GroupExpensesPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline, - size: 80, - color: Colors.red[300], - ), + Icon(Icons.error_outline, size: 80, color: Colors.red[300]), const SizedBox(height: 16), Text( 'Erreur', @@ -190,10 +342,7 @@ class _GroupExpensesPageState extends State padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( message, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 16, color: Colors.grey[600]), textAlign: TextAlign.center, ), ), @@ -213,11 +362,7 @@ class _GroupExpensesPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.info_outline, - size: 80, - color: Colors.grey[400], - ), + Icon(Icons.info_outline, size: 80, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'Aucune donnée', @@ -230,10 +375,7 @@ class _GroupExpensesPageState extends State const SizedBox(height: 8), Text( message, - style: TextStyle( - fontSize: 16, - color: Colors.grey[500], - ), + style: TextStyle(fontSize: 16, color: Colors.grey[500]), textAlign: TextAlign.center, ), ], @@ -243,14 +385,12 @@ class _GroupExpensesPageState extends State void _showAddExpenseDialog() { final userState = context.read().state; - + if (userState is user_state.UserLoaded) { showDialog( context: context, - builder: (context) => AddExpenseDialog( - group: widget.group, - currentUser: userState.user, - ), + builder: (context) => + AddExpenseDialog(group: widget.group, currentUser: userState.user), ); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -261,4 +401,96 @@ class _GroupExpensesPageState extends State ); } } -} \ No newline at end of file + + 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/activities/activities_page.dart b/lib/components/activities/activities_page.dart index b067f1c..f88487f 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -8,6 +8,8 @@ import '../../models/activity.dart'; import '../../services/activity_cache_service.dart'; import '../loading/laoding_content.dart'; +import '../../blocs/user/user_bloc.dart'; +import '../../blocs/user/user_state.dart'; class ActivitiesPage extends StatefulWidget { final Trip trip; @@ -32,8 +34,7 @@ class _ActivitiesPageState extends State List _tripActivities = []; List _approvedActivities = []; bool _isLoadingTripActivities = false; - int _totalGoogleActivitiesRequested = - 0; // Compteur pour les recherches progressives + bool _autoReloadInProgress = false; // Protection contre les rechargements en boucle int _lastAutoReloadTriggerCount = @@ -999,9 +1000,11 @@ class _ActivitiesPageState extends State } void _voteForActivity(String activityId, int vote) { - // TODO: Récupérer l'ID utilisateur actuel - // Pour l'instant, on utilise l'ID du créateur du voyage pour que le vote compte - final userId = widget.trip.createdBy; + // Récupérer l'ID utilisateur actuel depuis le UserBloc + final userState = context.read().state; + final userId = userState is UserLoaded + ? userState.user.id + : widget.trip.createdBy; // Vérifier si l'activité existe dans la liste locale pour vérifier le vote // (car l'objet activity passé peut venir d'une liste filtrée ou autre) @@ -1122,7 +1125,7 @@ class _ActivitiesPageState extends State 6; // Activités actuelles + ce qui manque + buffer de 6 // Mettre à jour le compteur et recharger avec le nouveau total - _totalGoogleActivitiesRequested = newTotalToRequest; + _loadMoreGoogleActivitiesWithTotal(newTotalToRequest); // Libérer le verrou après un délai @@ -1135,7 +1138,6 @@ class _ActivitiesPageState extends State } void _searchGoogleActivities() { - _totalGoogleActivitiesRequested = 6; // Reset du compteur _autoReloadInProgress = false; // Reset des protections _lastAutoReloadTriggerCount = 0; @@ -1166,7 +1168,6 @@ class _ActivitiesPageState extends State } void _resetAndSearchGoogleActivities() { - _totalGoogleActivitiesRequested = 6; // Reset du compteur _autoReloadInProgress = false; // Reset des protections _lastAutoReloadTriggerCount = 0; @@ -1203,8 +1204,6 @@ class _ActivitiesPageState extends State final currentCount = currentState.searchResults.length; final newTotal = currentCount + 6; - _totalGoogleActivitiesRequested = newTotal; - // Utiliser les coordonnées pré-géolocalisées du voyage si disponibles if (widget.trip.hasCoordinates) { context.read().add( diff --git a/lib/components/activities/add_activity_bottom_sheet.dart b/lib/components/activities/add_activity_bottom_sheet.dart index dd86c6f..ca2f39d 100644 --- a/lib/components/activities/add_activity_bottom_sheet.dart +++ b/lib/components/activities/add_activity_bottom_sheet.dart @@ -57,7 +57,7 @@ class _AddActivityBottomSheetState extends State { height: 4, margin: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withOpacity(0.3), + color: theme.colorScheme.onSurface.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/components/group/chat_group_content.dart b/lib/components/group/chat_group_content.dart index 1b2d5c0..d587a39 100644 --- a/lib/components/group/chat_group_content.dart +++ b/lib/components/group/chat_group_content.dart @@ -12,7 +12,7 @@ import '../../models/message.dart'; import '../../repositories/group_repository.dart'; /// Chat group content widget for group messaging functionality. -/// +/// /// This widget provides a complete chat interface for group members to /// communicate within a travel group. Features include: /// - Real-time message loading and sending @@ -20,7 +20,7 @@ import '../../repositories/group_repository.dart'; /// - Message reactions (like/unlike) /// - Scroll-to-bottom functionality /// - Message status indicators -/// +/// /// The widget integrates with MessageBloc for state management and /// handles various message operations through the bloc pattern. class ChatGroupContent extends StatefulWidget { @@ -28,13 +28,10 @@ class ChatGroupContent extends StatefulWidget { final Group group; /// Creates a chat group content widget. - /// + /// /// Args: /// [group]: The group object containing group details and ID - const ChatGroupContent({ - super.key, - required this.group, - }); + const ChatGroupContent({super.key, required this.group}); @override State createState() => _ChatGroupContentState(); @@ -43,16 +40,16 @@ class ChatGroupContent extends StatefulWidget { class _ChatGroupContentState extends State { /// Controller for the message input field final _messageController = TextEditingController(); - + /// Controller for managing scroll position in the message list final _scrollController = ScrollController(); - + /// Currently selected message for editing (null if not editing) Message? _editingMessage; /// Repository pour gérer les groupes final _groupRepository = GroupRepository(); - + /// Subscription pour écouter les changements des membres du groupe late StreamSubscription> _membersSubscription; @@ -61,18 +58,20 @@ class _ChatGroupContentState extends State { super.initState(); // Load messages when the widget initializes context.read().add(LoadMessages(widget.group.id)); - + // Écouter les changements des membres du groupe - _membersSubscription = _groupRepository.watchGroupMembers(widget.group.id).listen((updatedMembers) { - if (mounted) { - setState(() { - widget.group.members.clear(); - widget.group.members.addAll(updatedMembers); + _membersSubscription = _groupRepository + .watchGroupMembers(widget.group.id) + .listen((updatedMembers) { + if (mounted) { + setState(() { + widget.group.members.clear(); + widget.group.members.addAll(updatedMembers); + }); + } }); - } - }); } - + @override void dispose() { _messageController.dispose(); @@ -82,11 +81,11 @@ class _ChatGroupContentState extends State { } /// Sends a new message or updates an existing message. - /// + /// /// Handles both sending new messages and editing existing ones based /// on the current editing state. Validates input and clears the input /// field after successful submission. - /// + /// /// Args: /// [currentUser]: The user sending or editing the message void _sendMessage(user_state.UserModel currentUser) { @@ -96,33 +95,33 @@ class _ChatGroupContentState extends State { if (_editingMessage != null) { // Edit mode - update existing message context.read().add( - UpdateMessage( - groupId: widget.group.id, - messageId: _editingMessage!.id, - newText: messageText, - ), - ); + UpdateMessage( + groupId: widget.group.id, + messageId: _editingMessage!.id, + newText: messageText, + ), + ); _cancelEdit(); } else { // Send mode - create new message context.read().add( - SendMessage( - groupId: widget.group.id, - text: messageText, - senderId: currentUser.id, - senderName: currentUser.prenom, - ), - ); + SendMessage( + groupId: widget.group.id, + text: messageText, + senderId: currentUser.id, + senderName: currentUser.prenom, + ), + ); } _messageController.clear(); } /// Initiates editing mode for a selected message. - /// + /// /// Sets the message as the currently editing message and populates /// the input field with the message text for modification. - /// + /// /// Args: /// [message]: The message to edit void _editMessage(Message message) { @@ -133,7 +132,7 @@ class _ChatGroupContentState extends State { } /// Cancels the current editing operation. - /// + /// /// Resets the editing state and clears the input field, /// returning to normal message sending mode. void _cancelEdit() { @@ -144,46 +143,43 @@ class _ChatGroupContentState extends State { } /// Deletes a message from the group chat. - /// + /// /// Sends a delete event to the MessageBloc to remove the specified /// message from the group's message history. - /// + /// /// Args: /// [messageId]: The ID of the message to delete void _deleteMessage(String messageId) { context.read().add( - DeleteMessage( - groupId: widget.group.id, - messageId: messageId, - ), - ); + DeleteMessage(groupId: widget.group.id, messageId: messageId), + ); } void _reactToMessage(String messageId, String userId, String reaction) { context.read().add( - ReactToMessage( - groupId: widget.group.id, - messageId: messageId, - userId: userId, - reaction: reaction, - ), - ); + ReactToMessage( + groupId: widget.group.id, + messageId: messageId, + userId: userId, + reaction: reaction, + ), + ); } void _removeReaction(String messageId, String userId) { context.read().add( - RemoveReaction( - groupId: widget.group.id, - messageId: messageId, - userId: userId, - ), - ); + RemoveReaction( + groupId: widget.group.id, + messageId: messageId, + userId: userId, + ), + ); } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - + return BlocBuilder( builder: (context, userState) { if (userState is! user_state.UserLoaded) { @@ -203,7 +199,10 @@ class _ChatGroupContentState extends State { Text(widget.group.name, style: const TextStyle(fontSize: 18)), Text( '${widget.group.members.length} membre${widget.group.members.length > 1 ? 's' : ''}', - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.normal), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ), ), ], ), @@ -255,7 +254,8 @@ class _ChatGroupContentState extends State { itemBuilder: (context, index) { final message = state.messages[index]; final isMe = message.senderId == currentUser.id; - final showDate = index == 0 || + final showDate = + index == 0 || !_isSameDay( state.messages[index - 1].timestamp, message.timestamp, @@ -263,8 +263,14 @@ class _ChatGroupContentState extends State { return Column( children: [ - if (showDate) _buildDateSeparator(message.timestamp), - _buildMessageBubble(message, isMe, isDark, currentUser.id), + if (showDate) + _buildDateSeparator(message.timestamp), + _buildMessageBubble( + message, + isMe, + isDark, + currentUser.id, + ), ], ); }, @@ -280,14 +286,15 @@ class _ChatGroupContentState extends State { if (_editingMessage != null) Container( color: isDark ? Colors.blue[900] : Colors.blue[100], - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Row( children: [ const Icon(Icons.edit, size: 20), const SizedBox(width: 8), - const Expanded( - child: Text('Modification du message'), - ), + const Expanded(child: Text('Modification du message')), IconButton( icon: const Icon(Icons.close), onPressed: _cancelEdit, @@ -315,11 +322,13 @@ class _ChatGroupContentState extends State { child: TextField( controller: _messageController, decoration: InputDecoration( - hintText: _editingMessage != null - ? 'Modifier le message...' + hintText: _editingMessage != null + ? 'Modifier le message...' : 'Écrire un message...', filled: true, - fillColor: isDark ? Colors.grey[850] : Colors.grey[100], + fillColor: isDark + ? Colors.grey[850] + : Colors.grey[100], border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, @@ -336,9 +345,13 @@ class _ChatGroupContentState extends State { const SizedBox(width: 8), IconButton( onPressed: () => _sendMessage(currentUser), - icon: Icon(_editingMessage != null ? Icons.check : Icons.send), + icon: Icon( + _editingMessage != null ? Icons.check : Icons.send, + ), style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of( + context, + ).colorScheme.primary, foregroundColor: Colors.white, padding: const EdgeInsets.all(12), ), @@ -361,27 +374,17 @@ class _ChatGroupContentState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.chat_bubble_outline, - size: 80, - color: Colors.grey[400], - ), + Icon(Icons.chat_bubble_outline, size: 80, color: Colors.grey[400]), const SizedBox(height: 16), const Text( 'Aucun message', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( 'Commencez la conversation !', textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), ], ), @@ -389,10 +392,15 @@ class _ChatGroupContentState extends State { ); } - Widget _buildMessageBubble(Message message, bool isMe, bool isDark, String currentUserId) { + Widget _buildMessageBubble( + Message message, + bool isMe, + bool isDark, + String currentUserId, + ) { final Color bubbleColor; final Color textColor; - + if (isMe) { bubbleColor = isDark ? const Color(0xFF1E3A5F) : const Color(0xFF90CAF9); textColor = isDark ? Colors.white : Colors.black87; @@ -402,42 +410,48 @@ class _ChatGroupContentState extends State { } // Trouver le membre qui a envoyé le message pour récupérer son pseudo actuel - final senderMember = widget.group.members.firstWhere( - (m) => m.userId == message.senderId, - orElse: () => null as dynamic, - ) as dynamic; + final senderMember = + widget.group.members.firstWhere( + (m) => m.userId == message.senderId, + orElse: () => null as dynamic, + ) + as dynamic; // Utiliser le pseudo actuel du membre, ou le senderName en fallback - final displayName = senderMember != null ? senderMember.pseudo : message.senderName; + final displayName = senderMember != null + ? senderMember.pseudo + : message.senderName; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: GestureDetector( - onLongPress: () => _showMessageOptions(context, message, isMe, currentUserId), + onLongPress: () => + _showMessageOptions(context, message, isMe, currentUserId), child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( - mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ // Avatar du sender (seulement pour les autres messages) if (!isMe) ...[ CircleAvatar( radius: 16, - backgroundImage: (senderMember != null && - senderMember.profilePictureUrl != null && - senderMember.profilePictureUrl!.isNotEmpty) + backgroundImage: + (senderMember != null && + senderMember.profilePictureUrl != null && + senderMember.profilePictureUrl!.isNotEmpty) ? NetworkImage(senderMember.profilePictureUrl!) : null, - child: (senderMember == null || - senderMember.profilePictureUrl == null || - senderMember.profilePictureUrl!.isEmpty) + child: + (senderMember == null || + senderMember.profilePictureUrl == null || + senderMember.profilePictureUrl!.isEmpty) ? Text( - displayName.isNotEmpty - ? displayName[0].toUpperCase() + displayName.isNotEmpty + ? displayName[0].toUpperCase() : '?', style: const TextStyle(fontSize: 12), ) @@ -445,10 +459,13 @@ class _ChatGroupContentState extends State { ), const SizedBox(width: 8), ], - + Container( margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, ), @@ -462,7 +479,9 @@ class _ChatGroupContentState extends State { ), ), child: Column( - crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (!isMe) ...[ Text( @@ -476,11 +495,17 @@ class _ChatGroupContentState extends State { const SizedBox(height: 4), ], Text( - message.isDeleted ? 'a supprimé un message' : message.text, + message.isDeleted + ? 'a supprimé un message' + : message.text, style: TextStyle( fontSize: 15, - color: message.isDeleted ? textColor.withValues(alpha: 0.5) : textColor, - fontStyle: message.isDeleted ? FontStyle.italic : FontStyle.normal, + color: message.isDeleted + ? textColor.withValues(alpha: 0.5) + : textColor, + fontStyle: message.isDeleted + ? FontStyle.italic + : FontStyle.normal, ), ), const SizedBox(height: 4), @@ -528,7 +553,7 @@ class _ChatGroupContentState extends State { List _buildReactionChips(Message message, String currentUserId) { final reactionCounts = >{}; - + // Grouper les réactions par emoji message.reactions.forEach((userId, emoji) { reactionCounts.putIfAbsent(emoji, () => []).add(userId); @@ -550,7 +575,7 @@ class _ChatGroupContentState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: hasReacted + color: hasReacted ? Colors.blue.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), @@ -565,7 +590,10 @@ class _ChatGroupContentState extends State { const SizedBox(width: 2), Text( '${userIds.length}', - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -574,7 +602,12 @@ class _ChatGroupContentState extends State { }).toList(); } - void _showMessageOptions(BuildContext context, Message message, bool isMe, String currentUserId) { + void _showMessageOptions( + BuildContext context, + Message message, + bool isMe, + String currentUserId, + ) { showModalBottomSheet( context: context, builder: (context) => SafeArea( @@ -609,7 +642,10 @@ class _ChatGroupContentState extends State { ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), - title: const Text('Supprimer', style: TextStyle(color: Colors.red)), + title: const Text( + 'Supprimer', + style: TextStyle(color: Colors.red), + ), onTap: () { Navigator.pop(context); _showDeleteConfirmation(context, message.id); @@ -712,20 +748,23 @@ class _ChatGroupContentState extends State { final member = widget.group.members[index]; final initials = member.pseudo.isNotEmpty ? member.pseudo[0].toUpperCase() - : (member.firstName.isNotEmpty - ? member.firstName[0].toUpperCase() - : '?'); - + : (member.firstName.isNotEmpty + ? member.firstName[0].toUpperCase() + : '?'); + // Construire le nom complet final fullName = '${member.firstName} ${member.lastName}'.trim(); - + return ListTile( leading: CircleAvatar( - backgroundImage: (member.profilePictureUrl != null && - member.profilePictureUrl!.isNotEmpty) + backgroundImage: + (member.profilePictureUrl != null && + member.profilePictureUrl!.isNotEmpty) ? NetworkImage(member.profilePictureUrl!) : null, - child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty) + child: + (member.profilePictureUrl == null || + member.profilePictureUrl!.isEmpty) ? Text(initials) : null, ), @@ -743,8 +782,11 @@ class _ChatGroupContentState extends State { ), ], ), - subtitle: member.role == 'admin' - ? const Text('Administrateur', style: TextStyle(fontSize: 12)) + subtitle: member.role == 'admin' + ? const Text( + 'Administrateur', + style: TextStyle(fontSize: 12), + ) : null, trailing: IconButton( icon: const Icon(Icons.edit, size: 18), @@ -774,7 +816,8 @@ class _ChatGroupContentState extends State { showDialog( context: context, builder: (context) => AlertDialog( - backgroundColor: theme.dialogBackgroundColor, + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Changer le pseudo', style: theme.textTheme.titleLarge?.copyWith( @@ -785,9 +828,7 @@ class _ChatGroupContentState extends State { controller: pseudoController, decoration: InputDecoration( hintText: 'Nouveau pseudo', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -825,11 +866,11 @@ class _ChatGroupContentState extends State { try { final updatedMember = member.copyWith(pseudo: newPseudo); await _groupRepository.addMember(widget.group.id, updatedMember); - + if (mounted) { // Le stream listener va automatiquement mettre à jour les membres // Pas besoin de fermer le dialog ou de faire un refresh manuel - + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Pseudo modifié en "$newPseudo"'), @@ -848,4 +889,4 @@ class _ChatGroupContentState extends State { } } } -} \ No newline at end of file +} diff --git a/lib/components/group/group_content.dart b/lib/components/group/group_content.dart index 0f2253c..8d04b7a 100644 --- a/lib/components/group/group_content.dart +++ b/lib/components/group/group_content.dart @@ -144,12 +144,9 @@ class _GroupContentState extends State { final color = colors[group.name.hashCode.abs() % colors.length]; // Membres de manière simple - String memberInfo = '${group.members.length} membre(s)'; + String memberInfo = '${group.memberIds.length} membre(s)'; if (group.members.isNotEmpty) { - final names = group.members - .take(2) - .map((m) => m.firstName) - .join(', '); + final names = group.members.take(2).map((m) => m.firstName).join(', '); memberInfo += '\n$names'; } diff --git a/lib/components/home/calendar/calendar_page.dart b/lib/components/home/calendar/calendar_page.dart index 29768d0..a00e1f6 100644 --- a/lib/components/home/calendar/calendar_page.dart +++ b/lib/components/home/calendar/calendar_page.dart @@ -290,10 +290,13 @@ class _CalendarPageState extends State { ), // Zone de drop pour le calendrier DragTarget( - onWillAccept: (data) => true, - onAccept: (activity) { + onWillAcceptWithDetails: (details) => true, + onAcceptWithDetails: (details) { if (_selectedDay != null) { - _selectTimeAndSchedule(activity, _selectedDay!); + _selectTimeAndSchedule( + details.data, + _selectedDay!, + ); } }, builder: (context, candidateData, rejectedData) { diff --git a/lib/components/home/create_trip_content.dart b/lib/components/home/create_trip_content.dart index 89b9e48..ba8201d 100644 --- a/lib/components/home/create_trip_content.dart +++ b/lib/components/home/create_trip_content.dart @@ -22,9 +22,10 @@ import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../services/place_image_service.dart'; import '../../services/trip_geocoding_service.dart'; +import '../../services/logger_service.dart'; /// Create trip content widget for trip creation and editing functionality. -/// +/// /// This widget provides a comprehensive form interface for creating new trips /// or editing existing ones. Key features include: /// - Trip creation with validation @@ -34,22 +35,19 @@ import '../../services/trip_geocoding_service.dart'; /// - Group creation and member management /// - Account setup for expense tracking /// - Integration with mapping services for location selection -/// +/// /// The widget handles both creation and editing modes based on the /// provided tripToEdit parameter. class CreateTripContent extends StatefulWidget { /// Optional trip to edit. If null, creates a new trip final Trip? tripToEdit; - + /// Creates a create trip content widget. - /// + /// /// Args: /// [tripToEdit]: Optional trip to edit. If provided, the form will /// be populated with existing trip data for editing - const CreateTripContent({ - super.key, - this.tripToEdit, - }); + const CreateTripContent({super.key, this.tripToEdit}); @override State createState() => _CreateTripContentState(); @@ -58,17 +56,17 @@ class CreateTripContent extends StatefulWidget { class _CreateTripContentState extends State { /// Service for handling and displaying errors final _errorService = ErrorService(); - + /// Form validation key final _formKey = GlobalKey(); - + /// Text controllers for form fields final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); final _locationController = TextEditingController(); final _budgetController = TextEditingController(); final _participantController = TextEditingController(); - + /// Services for user and group operations final _userService = UserService(); final _groupRepository = GroupRepository(); @@ -79,7 +77,7 @@ class _CreateTripContentState extends State { /// Trip date variables DateTime? _startDate; DateTime? _endDate; - + /// Loading and state management variables bool _isLoading = false; String? _createdTripId; @@ -127,7 +125,7 @@ class _CreateTripContentState extends State { void _onLocationChanged() { final query = _locationController.text.trim(); - + if (query.length < 2) { _hideSuggestions(); return; @@ -151,14 +149,14 @@ class _CreateTripContentState extends State { '?input=${Uri.encodeComponent(query)}' '&types=(cities)' '&language=fr' - '&key=$_apiKey' + '&key=$_apiKey', ); final response = await http.get(url); - + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK') { final predictions = data['predictions'] as List; setState(() { @@ -170,7 +168,7 @@ class _CreateTripContentState extends State { }).toList(); _isLoadingSuggestions = false; }); - + if (_placeSuggestions.isNotEmpty) { _showSuggestions(); } else { @@ -202,12 +200,14 @@ class _CreateTripContentState extends State { // Nouvelle méthode pour afficher les suggestions void _showSuggestions() { _hideSuggestions(); // Masquer d'abord les suggestions existantes - + if (_placeSuggestions.isEmpty) return; _suggestionsOverlay = OverlayEntry( builder: (context) => Positioned( - width: MediaQuery.of(context).size.width - 32, // Largeur du champ avec padding + width: + MediaQuery.of(context).size.width - + 32, // Largeur du champ avec padding child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, @@ -258,26 +258,32 @@ class _CreateTripContentState extends State { setState(() { _placeSuggestions = []; }); - + // Charger l'image du lieu sélectionné _loadPlaceImage(suggestion.description); } /// Charge l'image du lieu depuis Google Places API Future _loadPlaceImage(String location) async { - print('CreateTripContent: Chargement de l\'image pour: $location'); + LoggerService.info( + 'CreateTripContent: Chargement de l\'image pour: $location', + ); try { final imageUrl = await _placeImageService.getPlaceImageUrl(location); - print('CreateTripContent: Image URL reçue: $imageUrl'); + LoggerService.info('CreateTripContent: Image URL reçue: $imageUrl'); if (mounted) { setState(() { _selectedImageUrl = imageUrl; }); - print('CreateTripContent: État mis à jour avec imageUrl: $_selectedImageUrl'); + LoggerService.info( + 'CreateTripContent: État mis à jour avec imageUrl: $_selectedImageUrl', + ); } } catch (e) { - print('CreateTripContent: Erreur lors du chargement de l\'image: $e'); + LoggerService.error( + 'CreateTripContent: Erreur lors du chargement de l\'image: $e', + ); if (mounted) { _errorService.logError( 'create_trip_content.dart', @@ -337,7 +343,7 @@ class _CreateTripContentState extends State { }) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - + return TextFormField( controller: controller, validator: validator, @@ -349,42 +355,36 @@ class _CreateTripContentState extends State { decoration: InputDecoration( hintText: label, hintStyle: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), prefixIcon: Icon( icon, - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), suffixIcon: suffixIcon, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: isDarkMode - ? Colors.white.withOpacity(0.2) - : Colors.black.withOpacity(0.2), + color: isDarkMode + ? Colors.white.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.2), ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: isDarkMode - ? Colors.white.withOpacity(0.2) - : Colors.black.withOpacity(0.2), + color: isDarkMode + ? Colors.white.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.teal, - width: 2, - ), + borderSide: BorderSide(color: Colors.teal, width: 2), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), + borderSide: const BorderSide(color: Colors.red, width: 2), ), filled: true, fillColor: theme.cardColor, @@ -403,7 +403,7 @@ class _CreateTripContentState extends State { }) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), @@ -413,27 +413,27 @@ class _CreateTripContentState extends State { color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isDarkMode - ? Colors.white.withOpacity(0.2) - : Colors.black.withOpacity(0.2), + color: isDarkMode + ? Colors.white.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.2), ), ), child: Row( children: [ Icon( Icons.calendar_today, - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), size: 20, ), const SizedBox(width: 12), Text( - date != null - ? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}' - : 'mm/dd/yyyy', + date != null + ? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}' + : 'mm/dd/yyyy', style: theme.textTheme.bodyLarge?.copyWith( - color: date != null - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withOpacity(0.5), + color: date != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.5), ), ), ], @@ -442,11 +442,11 @@ class _CreateTripContentState extends State { ); } - @override + @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - + return BlocListener( listener: (context, tripState) { if (tripState is TripCreated) { @@ -454,7 +454,10 @@ class _CreateTripContentState extends State { _createGroupAndAccountForTrip(_createdTripId!); } else if (tripState is TripOperationSuccess) { if (mounted) { - _errorService.showSnackbar(message: tripState.message, isError: false); + _errorService.showSnackbar( + message: tripState.message, + isError: false, + ); setState(() { _isLoading = false; }); @@ -465,7 +468,10 @@ class _CreateTripContentState extends State { } } else if (tripState is TripError) { if (mounted) { - _errorService.showSnackbar(message: tripState.message, isError: true); + _errorService.showSnackbar( + message: tripState.message, + isError: true, + ); setState(() { _isLoading = false; }); @@ -478,7 +484,9 @@ class _CreateTripContentState extends State { return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: Text(isEditing ? 'Modifier le voyage' : 'Créer un voyage'), + title: Text( + isEditing ? 'Modifier le voyage' : 'Créer un voyage', + ), backgroundColor: theme.appBarTheme.backgroundColor, foregroundColor: theme.appBarTheme.foregroundColor, ), @@ -506,7 +514,10 @@ class _CreateTripContentState extends State { backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), + icon: Icon( + Icons.arrow_back, + color: theme.colorScheme.onSurface, + ), onPressed: () => Navigator.pop(context), ), ), @@ -519,7 +530,9 @@ class _CreateTripContentState extends State { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), + color: Colors.black.withValues( + alpha: isDarkMode ? 0.3 : 0.1, + ), blurRadius: 10, offset: const Offset(0, 5), ), @@ -544,7 +557,9 @@ class _CreateTripContentState extends State { Text( 'Donne un nom à ton voyage', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.7, + ), ), ), const SizedBox(height: 24), @@ -588,7 +603,9 @@ class _CreateTripContentState extends State { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : null, ), @@ -667,7 +684,9 @@ class _CreateTripContentState extends State { controller: _budgetController, label: 'Ex : 500', icon: Icons.euro, - keyboardType: TextInputType.numberWithOptions(decimal: true), + keyboardType: TextInputType.numberWithOptions( + decimal: true, + ), ), const SizedBox(height: 20), @@ -701,7 +720,10 @@ class _CreateTripContentState extends State { ), child: IconButton( onPressed: _addParticipant, - icon: const Icon(Icons.add, color: Colors.white), + icon: const Icon( + Icons.add, + color: Colors.white, + ), ), ), ], @@ -720,7 +742,7 @@ class _CreateTripContentState extends State { vertical: 8, ), decoration: BoxDecoration( - color: Colors.teal.withOpacity(0.1), + color: Colors.teal.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -728,10 +750,11 @@ class _CreateTripContentState extends State { children: [ Text( email, - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.teal, - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodySmall + ?.copyWith( + color: Colors.teal, + fontWeight: FontWeight.w500, + ), ), const SizedBox(width: 8), GestureDetector( @@ -758,7 +781,9 @@ class _CreateTripContentState extends State { width: double.infinity, height: 56, child: ElevatedButton( - onPressed: _isLoading ? null : () => _saveTrip(userState.user), + onPressed: _isLoading + ? null + : () => _saveTrip(userState.user), style: ElevatedButton.styleFrom( backgroundColor: Colors.teal, foregroundColor: Colors.white, @@ -773,15 +798,20 @@ class _CreateTripContentState extends State { height: 20, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), ), ) : Text( - isEditing ? 'Modifier le voyage' : 'Créer le voyage', - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), + isEditing + ? 'Modifier le voyage' + : 'Créer le voyage', + style: theme.textTheme.titleMedium + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), ), ), @@ -846,15 +876,18 @@ class _CreateTripContentState extends State { final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); if (!emailRegex.hasMatch(email)) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Email invalide'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Email invalide'))); } return; } if (_participants.contains(email)) { if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Ce participant est déjà ajouté'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ce participant est déjà ajouté')), + ); } return; } @@ -879,7 +912,7 @@ class _CreateTripContentState extends State { ) async { final groupBloc = context.read(); final accountBloc = context.read(); - + try { final group = await _groupRepository.getGroupByTripId(tripId); @@ -900,7 +933,9 @@ class _CreateTripContentState extends State { final currentMemberIds = currentMembers.map((m) => m.userId).toSet(); final newMemberIds = newMembers.map((m) => m.userId).toSet(); - final membersToAdd = newMembers.where((m) => !currentMemberIds.contains(m.userId)).toList(); + final membersToAdd = newMembers + .where((m) => !currentMemberIds.contains(m.userId)) + .toList(); final membersToRemove = currentMembers .where((m) => !newMemberIds.contains(m.userId) && m.role != 'admin') @@ -961,14 +996,16 @@ class _CreateTripContentState extends State { role: 'admin', profilePictureUrl: currentUser.profilePictureUrl, ), - ...participantsData.map((p) => GroupMember( - userId: p['id'] as String, - firstName: p['firstName'] as String, - lastName: p['lastName'] as String? ?? '', - pseudo: p['firstName'] as String, - role: 'member', - profilePictureUrl: p['profilePictureUrl'] as String?, - )), + ...participantsData.map( + (p) => GroupMember( + userId: p['id'] as String, + firstName: p['firstName'] as String, + lastName: p['lastName'] as String? ?? '', + pseudo: p['firstName'] as String, + role: 'member', + profilePictureUrl: p['profilePictureUrl'] as String?, + ), + ), ]; return groupMembers; } @@ -976,9 +1013,8 @@ class _CreateTripContentState extends State { Future _createGroupAndAccountForTrip(String tripId) async { final groupBloc = context.read(); final accountBloc = context.read(); - + try { - final userState = context.read().state; if (userState is! user_state.UserLoaded) { throw Exception('Utilisateur non connecté'); @@ -998,21 +1034,19 @@ class _CreateTripContentState extends State { throw Exception('Erreur lors de la création des membres du groupe'); } - groupBloc.add(CreateGroupWithMembers( - group: group, - members: groupMembers, - )); + groupBloc.add( + CreateGroupWithMembers(group: group, members: groupMembers), + ); final account = Account( id: '', tripId: tripId, name: _titleController.text.trim(), ); - accountBloc.add(CreateAccountWithMembers( - account: account, - members: groupMembers, - )); - + accountBloc.add( + CreateAccountWithMembers(account: account, members: groupMembers), + ); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1025,7 +1059,6 @@ class _CreateTripContentState extends State { }); Navigator.pop(context); } - } catch (e) { _errorService.logError( 'create_trip_content.dart', @@ -1034,10 +1067,7 @@ class _CreateTripContentState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), ); setState(() { _isLoading = false; @@ -1046,8 +1076,6 @@ class _CreateTripContentState extends State { } } - - Future _saveTrip(user_state.UserModel currentUser) async { if (!_formKey.currentState!.validate()) { return; @@ -1070,7 +1098,9 @@ class _CreateTripContentState extends State { try { final participantsData = await _getParticipantsData(_participants); - List participantIds = participantsData.map((p) => p['id'] as String).toList(); + List participantIds = participantsData + .map((p) => p['id'] as String) + .toList(); if (!participantIds.contains(currentUser.id)) { participantIds.insert(0, currentUser.id); @@ -1101,7 +1131,9 @@ class _CreateTripContentState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Voyage créé sans géolocalisation (pas d\'impact sur les fonctionnalités)'), + content: Text( + 'Voyage créé sans géolocalisation (pas d\'impact sur les fonctionnalités)', + ), backgroundColor: Colors.orange, duration: Duration(seconds: 2), ), @@ -1114,16 +1146,21 @@ class _CreateTripContentState extends State { tripBloc.add(TripUpdateRequested(trip: tripWithCoordinates)); // Mettre à jour le groupe ET les comptes avec les nouveaux participants - if (widget.tripToEdit != null && widget.tripToEdit!.id != null && widget.tripToEdit!.id!.isNotEmpty) { - print('🔄 [CreateTrip] Mise à jour du groupe et du compte pour le voyage ID: ${widget.tripToEdit!.id}'); - print('👥 Participants: ${participantsData.map((p) => p['id']).toList()}'); + if (widget.tripToEdit != null && + widget.tripToEdit!.id != null && + widget.tripToEdit!.id!.isNotEmpty) { + LoggerService.info( + '🔄 [CreateTrip] Mise à jour du groupe et du compte pour le voyage ID: ${widget.tripToEdit!.id}', + ); + LoggerService.info( + '👥 Participants: ${participantsData.map((p) => p['id']).toList()}', + ); await _updateGroupAndAccountMembers( widget.tripToEdit!.id!, currentUser, participantsData, ); } - } else { // Mode création - Le groupe sera créé dans le listener TripCreated tripBloc.add(TripCreateRequested(trip: tripWithCoordinates)); @@ -1131,10 +1168,7 @@ class _CreateTripContentState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), ); setState(() { @@ -1144,7 +1178,9 @@ class _CreateTripContentState extends State { } } - Future>> _getParticipantsData(List emails) async { + Future>> _getParticipantsData( + List emails, + ) async { List> participantsData = []; for (String email in emails) { @@ -1188,8 +1224,5 @@ class PlaceSuggestion { final String placeId; final String description; - PlaceSuggestion({ - required this.placeId, - required this.description, - }); -} \ No newline at end of file + PlaceSuggestion({required this.placeId, required this.description}); +} diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 97ac0be..c38e2df 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -100,7 +100,8 @@ class _ShowTripDetailsContentState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - backgroundColor: theme.dialogBackgroundColor, + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ouvrir la carte', style: theme.textTheme.titleLarge?.copyWith( @@ -612,7 +613,8 @@ class _ShowTripDetailsContentState extends State { showDialog( context: context, builder: (context) => AlertDialog( - backgroundColor: theme.dialogBackgroundColor, + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Confirmer la suppression', style: theme.textTheme.titleLarge?.copyWith( @@ -814,7 +816,8 @@ class _ShowTripDetailsContentState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - backgroundColor: theme.dialogBackgroundColor, + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ajouter un participant', style: theme.textTheme.titleLarge?.copyWith( diff --git a/lib/components/loading/laoding_content.dart b/lib/components/loading/laoding_content.dart index e629ebf..5cf4585 100644 --- a/lib/components/loading/laoding_content.dart +++ b/lib/components/loading/laoding_content.dart @@ -57,7 +57,7 @@ class _LoadingContentState extends State widget.onComplete!(); } } catch (e) { - print('Erreur lors de la tâche en arrière-plan: $e'); + debugPrint('Erreur lors de la tâche en arrière-plan: $e'); } } } diff --git a/lib/components/map/map_content.dart b/lib/components/map/map_content.dart index 7311683..b5ae4ec 100644 --- a/lib/components/map/map_content.dart +++ b/lib/components/map/map_content.dart @@ -21,19 +21,20 @@ class _MapContentState extends State { bool _isLoadingLocation = false; bool _isSearching = false; Position? _currentPosition; - + final Set _markers = {}; final Set _circles = {}; - + List _suggestions = []; - + static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? ''; @override void initState() { super.initState(); // Si une recherche initiale est fournie, la pré-remplir et lancer la recherche - if (widget.initialSearchQuery != null && widget.initialSearchQuery!.isNotEmpty) { + if (widget.initialSearchQuery != null && + widget.initialSearchQuery!.isNotEmpty) { _searchController.text = widget.initialSearchQuery!; // Lancer la recherche automatiquement après un court délai pour laisser l'interface se charger Future.delayed(const Duration(milliseconds: 500), () { @@ -65,17 +66,19 @@ class _MapContentState extends State { 'https://maps.googleapis.com/maps/api/place/autocomplete/json' '?input=${Uri.encodeComponent(query)}' '&key=$_apiKey' - '&language=fr' + '&language=fr', ); final response = await http.get(url); + if (!mounted) return; + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK') { final predictions = data['predictions'] as List; - + if (predictions.isNotEmpty) { // Prendre automatiquement la première suggestion final firstPrediction = predictions.first; @@ -83,7 +86,7 @@ class _MapContentState extends State { placeId: firstPrediction['place_id'], description: firstPrediction['description'], ); - + // Effectuer la sélection automatique await _selectPlaceForInitialSearch(suggestion); } else { @@ -117,9 +120,11 @@ class _MapContentState extends State { final response = await http.get(url); + if (!mounted) return; + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK') { final location = data['result']['geometry']['location']; final lat = location['lat']; @@ -132,7 +137,7 @@ class _MapContentState extends State { setState(() { // Garder le marqueur de position utilisateur s'il existe _markers.removeWhere((m) => m.markerId.value != 'user_location'); - + // Ajouter le nouveau marqueur de lieu _markers.add( Marker( @@ -230,9 +235,7 @@ class _MapContentState extends State { const size = 120.0; // Dessiner l'icône person_pin_circle en bleu - final iconPainter = TextPainter( - textDirection: TextDirection.ltr, - ); + final iconPainter = TextPainter(textDirection: TextDirection.ltr); iconPainter.text = TextSpan( text: String.fromCharCode(Icons.person_pin_circle.codePoint), style: TextStyle( @@ -242,26 +245,20 @@ class _MapContentState extends State { ), ); iconPainter.layout(); - iconPainter.paint( - canvas, - Offset( - (size - iconPainter.width) / 2, - 0, - ), - ); + iconPainter.paint(canvas, Offset((size - iconPainter.width) / 2, 0)); final picture = pictureRecorder.endRecording(); final image = await picture.toImage(size.toInt(), size.toInt()); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); - return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List()); + return BitmapDescriptor.bytes(bytes!.buffer.asUint8List()); } // Ajouter le marqueur avec l'icône personnalisée Future _addUserLocationMarker(LatLng position) async { _markers.clear(); _circles.clear(); - + // Ajouter un cercle de précision _circles.add( Circle( @@ -284,10 +281,14 @@ class _MapContentState extends State { markerId: const MarkerId('user_location'), position: position, icon: icon, - anchor: const Offset(0.5, 0.85), // Ancrer au bas de l'icône (le point du pin) + anchor: const Offset( + 0.5, + 0.85, + ), // Ancrer au bas de l'icône (le point du pin) infoWindow: InfoWindow( title: 'Ma position', - snippet: 'Lat: ${position.latitude.toStringAsFixed(4)}, Lng: ${position.longitude.toStringAsFixed(4)}', + snippet: + 'Lat: ${position.latitude.toStringAsFixed(4)}, Lng: ${position.longitude.toStringAsFixed(4)}', ), ), ); @@ -311,23 +312,27 @@ class _MapContentState extends State { 'https://maps.googleapis.com/maps/api/place/autocomplete/json' '?input=${Uri.encodeComponent(query)}' '&key=$_apiKey' - '&language=fr' + '&language=fr', ); final response = await http.get(url); + if (!mounted) return; + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK') { final predictions = data['predictions'] as List; - + setState(() { _suggestions = predictions - .map((p) => PlaceSuggestion( - placeId: p['place_id'], - description: p['description'], - )) + .map( + (p) => PlaceSuggestion( + placeId: p['place_id'], + description: p['description'], + ), + ) .toList(); _isSearching = false; }); @@ -363,9 +368,11 @@ class _MapContentState extends State { final response = await http.get(url); + if (!mounted) return; + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK') { final location = data['result']['geometry']['location']; final lat = location['lat']; @@ -378,7 +385,7 @@ class _MapContentState extends State { setState(() { // Garder le marqueur de position utilisateur _markers.removeWhere((m) => m.markerId.value != 'user_location'); - + // Ajouter le nouveau marqueur de lieu _markers.add( Marker( @@ -394,7 +401,9 @@ class _MapContentState extends State { CameraUpdate.newLatLngZoom(newPosition, 15), ); - FocusScope.of(context).unfocus(); + if (mounted) { + FocusScope.of(context).unfocus(); + } } } } catch (e) { @@ -545,7 +554,10 @@ class _MapContentState extends State { : Icon(Icons.search, color: Colors.grey[700]), suffixIcon: _searchController.text.isNotEmpty ? IconButton( - icon: Icon(Icons.clear, color: Colors.grey[700]), + icon: Icon( + Icons.clear, + color: Colors.grey[700], + ), onPressed: () { _searchController.clear(); setState(() { @@ -567,7 +579,8 @@ class _MapContentState extends State { ), onChanged: (value) { // Ne pas rechercher si c'est juste le remplissage initial - if (widget.initialSearchQuery != null && value == widget.initialSearchQuery) { + if (widget.initialSearchQuery != null && + value == widget.initialSearchQuery) { return; } _searchPlaces(value); @@ -601,10 +614,8 @@ class _MapContentState extends State { shrinkWrap: true, padding: EdgeInsets.zero, itemCount: _suggestions.length, - separatorBuilder: (context, index) => Divider( - height: 1, - color: Colors.grey[300], - ), + separatorBuilder: (context, index) => + Divider(height: 1, color: Colors.grey[300]), itemBuilder: (context, index) { final suggestion = _suggestions[index]; return InkWell( @@ -664,8 +675,5 @@ class PlaceSuggestion { final String placeId; final String description; - PlaceSuggestion({ - required this.placeId, - required this.description, - }); -} \ No newline at end of file + PlaceSuggestion({required this.placeId, required this.description}); +} diff --git a/lib/components/settings/profile/profile_content.dart b/lib/components/settings/profile/profile_content.dart index 53fca14..bb7ab21 100644 --- a/lib/components/settings/profile/profile_content.dart +++ b/lib/components/settings/profile/profile_content.dart @@ -9,6 +9,7 @@ import '../../../blocs/user/user_bloc.dart'; import '../../../blocs/user/user_state.dart' as user_state; import '../../../blocs/user/user_event.dart' as user_event; import '../../../services/auth_service.dart'; +import '../../../services/logger_service.dart'; class ProfileContent extends StatelessWidget { ProfileContent({super.key}); @@ -19,7 +20,8 @@ class ProfileContent extends StatelessWidget { Widget build(BuildContext context) { return UserStateWrapper( builder: (context, user) { - final isEmailAuth = user.authMethod == 'email' || user.authMethod == null; + final isEmailAuth = + user.authMethod == 'email' || user.authMethod == null; return SingleChildScrollView( child: Column( @@ -40,10 +42,12 @@ class ProfileContent extends StatelessWidget { color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: Offset(0, 2), - ) + ), ], ), - child: user.profilePictureUrl != null && user.profilePictureUrl!.isNotEmpty + child: + user.profilePictureUrl != null && + user.profilePictureUrl!.isNotEmpty ? CircleAvatar( radius: 50, backgroundImage: NetworkImage( @@ -57,7 +61,9 @@ class ProfileContent extends StatelessWidget { ) : CircleAvatar( radius: 50, - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of( + context, + ).colorScheme.primary, child: Text( user.prenom.isNotEmpty ? user.prenom[0].toUpperCase() @@ -88,10 +94,7 @@ class ProfileContent extends StatelessWidget { // Email Text( user.email, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), textAlign: TextAlign.center, ), @@ -99,7 +102,10 @@ class ProfileContent extends StatelessWidget { // Badge de méthode de connexion Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: _getAuthMethodColor(user.authMethod, context), borderRadius: BorderRadius.circular(12), @@ -120,7 +126,10 @@ class ProfileContent extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: _getAuthMethodTextColor(user.authMethod, context), + color: _getAuthMethodTextColor( + user.authMethod, + context, + ), ), ), ], @@ -314,17 +323,13 @@ class ProfileContent extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: isDestructive - ? Colors.red + color: isDestructive + ? Colors.red : (isDarkMode ? Colors.white : Colors.black87), ), ), ), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Colors.grey[400], - ), + Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey[400]), ], ), ), @@ -355,7 +360,7 @@ class ProfileContent extends StatelessWidget { Color _getAuthMethodColor(String? authMethod, BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - + switch (authMethod) { case 'apple': return isDarkMode ? Colors.white : Colors.black87; @@ -368,7 +373,7 @@ class ProfileContent extends StatelessWidget { Color _getAuthMethodTextColor(String? authMethod, BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - + switch (authMethod) { case 'apple': return isDarkMode ? Colors.black87 : Colors.white; @@ -401,7 +406,9 @@ class ProfileContent extends StatelessWidget { children: [ CircleAvatar( radius: 50, - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of( + context, + ).colorScheme.primary, child: Text( prenomController.text.isNotEmpty ? prenomController.text[0].toUpperCase() @@ -422,7 +429,11 @@ class ProfileContent extends StatelessWidget { color: Theme.of(context).colorScheme.primary, ), child: IconButton( - icon: Icon(Icons.camera_alt, color: Colors.white, size: 20), + icon: Icon( + Icons.camera_alt, + color: Colors.white, + size: 20, + ), onPressed: () { _showPhotoPickerDialog(dialogContext); }, @@ -511,60 +522,66 @@ class ProfileContent extends StatelessWidget { void _showPhotoPickerDialog(BuildContext context) { // Récupérer les références AVANT que le modal ne se ferme final userBloc = context.read(); - + showModalBottomSheet( context: context, builder: (BuildContext sheetContext) { - return Container( - child: Wrap( - children: [ - ListTile( - leading: Icon(Icons.photo_library), - title: Text('Galerie'), - onTap: () { - Navigator.pop(sheetContext); - _pickImageFromGallery(context, userBloc); - }, - ), - ListTile( - leading: Icon(Icons.camera_alt), - title: Text('Caméra'), - onTap: () { - Navigator.pop(sheetContext); - _pickImageFromCamera(context, userBloc); - }, - ), - ListTile( - leading: Icon(Icons.close), - title: Text('Annuler'), - onTap: () => Navigator.pop(sheetContext), - ), - ], - ), + return Wrap( + children: [ + ListTile( + leading: Icon(Icons.photo_library), + title: Text('Galerie'), + onTap: () { + Navigator.pop(sheetContext); + _pickImageFromGallery(context, userBloc); + }, + ), + ListTile( + leading: Icon(Icons.camera_alt), + title: Text('Caméra'), + onTap: () { + Navigator.pop(sheetContext); + _pickImageFromCamera(context, userBloc); + }, + ), + ListTile( + leading: Icon(Icons.close), + title: Text('Annuler'), + onTap: () => Navigator.pop(sheetContext), + ), + ], ); }, ); } - Future _pickImageFromGallery(BuildContext context, UserBloc userBloc) async { + Future _pickImageFromGallery( + BuildContext context, + UserBloc userBloc, + ) async { try { final ImagePicker picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); - if (image != null) { + if (image != null && context.mounted) { await _uploadProfilePicture(context, image.path, userBloc); } } catch (e) { - _errorService.showError(message: 'Erreur lors de la sélection de l\'image'); + _errorService.showError( + message: 'Erreur lors de la sélection de l\'image', + ); } } - Future _pickImageFromCamera(BuildContext context, UserBloc userBloc) async { + Future _pickImageFromCamera( + BuildContext context, + UserBloc userBloc, + ) async { try { final ImagePicker picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image != null) { + if (image != null && context.mounted) { await _uploadProfilePicture(context, image.path, userBloc); } } catch (e) { @@ -572,17 +589,23 @@ class ProfileContent extends StatelessWidget { } } - Future _uploadProfilePicture(BuildContext context, String imagePath, UserBloc userBloc) async { + Future _uploadProfilePicture( + BuildContext context, + String imagePath, + UserBloc userBloc, + ) async { try { final File imageFile = File(imagePath); - + // Vérifier que le fichier existe if (!await imageFile.exists()) { _errorService.showError(message: 'Le fichier image n\'existe pas'); return; } - print('DEBUG: Taille du fichier: ${imageFile.lengthSync()} bytes'); + LoggerService.info( + 'DEBUG: Taille du fichier: ${imageFile.lengthSync()} bytes', + ); final userState = userBloc.state; if (userState is! user_state.UserLoaded) { @@ -591,30 +614,35 @@ class ProfileContent extends StatelessWidget { } final user = userState.user; - + // Créer un nom unique pour la photo - final String fileName = 'profile_${user.id}_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final String fileName = + 'profile_${user.id}_${DateTime.now().millisecondsSinceEpoch}.jpg'; final Reference storageRef = FirebaseStorage.instance .ref() .child('profile_pictures') .child(fileName); - print('DEBUG: Chemin Storage: ${storageRef.fullPath}'); - print('DEBUG: Upload en cours pour $fileName'); + LoggerService.info('DEBUG: Chemin Storage: ${storageRef.fullPath}'); + LoggerService.info('DEBUG: Upload en cours pour $fileName'); // Uploader l'image avec gestion d'erreur détaillée try { final uploadTask = storageRef.putFile(imageFile); - + // Écouter la progression uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) { - print('DEBUG: Progression: ${snapshot.bytesTransferred}/${snapshot.totalBytes}'); + LoggerService.info( + 'DEBUG: Progression: ${snapshot.bytesTransferred}/${snapshot.totalBytes}', + ); }); final snapshot = await uploadTask; - print('DEBUG: Upload terminé. État: ${snapshot.state}'); + LoggerService.info('DEBUG: Upload terminé. État: ${snapshot.state}'); } on FirebaseException catch (e) { - print('DEBUG: FirebaseException lors de l\'upload: ${e.code} - ${e.message}'); + LoggerService.error( + 'DEBUG: FirebaseException lors de l\'upload: ${e.code} - ${e.message}', + ); if (context.mounted) { _errorService.showError( message: 'Erreur Firebase: ${e.code}\n${e.message}', @@ -623,26 +651,22 @@ class ProfileContent extends StatelessWidget { return; } - print('DEBUG: Upload terminé, récupération de l\'URL'); + LoggerService.info('DEBUG: Upload terminé, récupération de l\'URL'); // Récupérer l'URL final String downloadUrl = await storageRef.getDownloadURL(); - print('DEBUG: URL obtenue: $downloadUrl'); + LoggerService.info('DEBUG: URL obtenue: $downloadUrl'); // Mettre à jour le profil avec l'URL en utilisant la référence sauvegardée du BLoC - print('DEBUG: Envoi de UserUpdated event au BLoC'); - userBloc.add( - user_event.UserUpdated({ - 'profilePictureUrl': downloadUrl, - }), - ); + LoggerService.info('DEBUG: Envoi de UserUpdated event au BLoC'); + userBloc.add(user_event.UserUpdated({'profilePictureUrl': downloadUrl})); // Attendre un peu que Firestore se mette à jour await Future.delayed(Duration(milliseconds: 500)); if (context.mounted) { - print('DEBUG: Affichage du succès'); + LoggerService.info('DEBUG: Affichage du succès'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Photo de profil mise à jour !'), @@ -651,8 +675,8 @@ class ProfileContent extends StatelessWidget { ); } } catch (e, stackTrace) { - print('DEBUG: Erreur lors de l\'upload: $e'); - print('DEBUG: Stack trace: $stackTrace'); + LoggerService.error('DEBUG: Erreur lors de l\'upload: $e'); + LoggerService.error('DEBUG: Stack trace: $stackTrace'); _errorService.logError( 'ProfileContent - _uploadProfilePicture', 'Erreur lors de l\'upload de la photo: $e\n$stackTrace', @@ -738,13 +762,15 @@ class ProfileContent extends StatelessWidget { email: user.email, ); - Navigator.of(dialogContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Mot de passe changé !'), - backgroundColor: Colors.green, - ), - ); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Mot de passe changé !'), + backgroundColor: Colors.green, + ), + ); + } } catch (e) { _errorService.showError( message: 'Erreur: Mot de passe actuel incorrect', @@ -801,13 +827,15 @@ class ProfileContent extends StatelessWidget { email: user.email, ); - Navigator.of(dialogContext).pop(); - context.read().add(user_event.UserLoggedOut()); - Navigator.pushNamedAndRemoveUntil( - context, - '/login', - (route) => false, - ); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + context.read().add(user_event.UserLoggedOut()); + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } } catch (e) { _errorService.showError( message: 'Erreur: Mot de passe incorrect', diff --git a/lib/models/activity.dart b/lib/models/activity.dart index e335898..272bb45 100644 --- a/lib/models/activity.dart +++ b/lib/models/activity.dart @@ -46,7 +46,7 @@ class Activity { /// Calcule le score total des votes int get totalVotes { - return votes.values.fold(0, (sum, vote) => sum + vote); + return votes.values.fold(0, (total, vote) => total + vote); } /// Calcule le nombre de votes positifs diff --git a/lib/models/expense.dart b/lib/models/expense.dart index e66806a..d45a799 100644 --- a/lib/models/expense.dart +++ b/lib/models/expense.dart @@ -4,107 +4,114 @@ import 'package:flutter/material.dart'; import 'expense_split.dart'; /// Enumeration of supported currencies for expenses. -/// +/// /// Each currency includes both a display symbol and standard currency code. enum ExpenseCurrency { /// Euro currency eur('€', 'EUR'), - /// US Dollar currency + + /// US Dollar currency usd('\$', 'USD'), + /// British Pound currency gbp('£', 'GBP'); const ExpenseCurrency(this.symbol, this.code); - + /// Currency symbol for display (e.g., €, $, £) final String symbol; - + /// Standard currency code (e.g., EUR, USD, GBP) final String code; } /// Enumeration of expense categories with display names and icons. -/// +/// /// Provides predefined categories for organizing travel expenses. enum ExpenseCategory { /// Restaurant and food expenses restaurant('Restaurant', Icons.restaurant), + /// Transportation expenses transport('Transport', Icons.directions_car), + /// Accommodation and lodging expenses accommodation('Accommodation', Icons.hotel), + /// Entertainment and activity expenses entertainment('Entertainment', Icons.local_activity), + /// Shopping expenses shopping('Shopping', Icons.shopping_bag), + /// Other miscellaneous expenses other('Other', Icons.category); const ExpenseCategory(this.displayName, this.icon); - + /// Human-readable display name for the category final String displayName; - + /// Icon representing the category final IconData icon; } /// Model representing a travel expense. -/// +/// /// This class encapsulates all information about an expense including /// amount, currency, category, who paid, how it's split among participants, /// and receipt information. It extends [Equatable] for value comparison. class Expense extends Equatable { /// Unique identifier for the expense final String id; - + /// ID of the group this expense belongs to final String groupId; - + /// Description of the expense final String description; - + /// Amount of the expense in the original currency final double amount; - + /// Currency of the expense final ExpenseCurrency currency; - + /// Amount converted to EUR for standardized calculations final double amountInEur; - + /// Category of the expense final ExpenseCategory category; - + /// ID of the user who paid for this expense final String paidById; - + /// Name of the user who paid for this expense final String paidByName; - + /// Date when the expense occurred final DateTime date; - + /// Timestamp when the expense was created final DateTime createdAt; - + /// Timestamp when the expense was last edited (null if never edited) final DateTime? editedAt; - + /// Whether this expense has been edited after creation final bool isEdited; - + /// Whether this expense has been archived final bool isArchived; - + /// URL to the receipt image (optional) final String? receiptUrl; - + /// List of expense splits showing how the cost is divided final List splits; /// Creates a new [Expense] instance. - /// + /// /// All parameters except [editedAt] and [receiptUrl] are required. const Expense({ required this.id, @@ -144,13 +151,17 @@ class Expense extends Equatable { paidByName: map['paidByName'] ?? '', date: _parseDateTime(map['date']), createdAt: _parseDateTime(map['createdAt']), - editedAt: map['editedAt'] != null ? _parseDateTime(map['editedAt']) : null, + 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() ?? [], + splits: + (map['splits'] as List?) + ?.map((s) => ExpenseSplit.fromMap(s)) + .toList() ?? + [], ); } @@ -243,25 +254,36 @@ class Expense extends Equatable { // Marquer comme archivé Expense copyWithArchived() { - return copyWith( - isArchived: true, - ); + return copyWith(isArchived: true); } // Ajouter/mettre à jour l'URL du reçu Expense copyWithReceipt(String receiptUrl) { - return copyWith( - receiptUrl: receiptUrl, - ); + return copyWith(receiptUrl: receiptUrl); } // Mettre à jour les splits Expense copyWithSplits(List newSplits) { - return copyWith( - splits: newSplits, - ); + return copyWith(splits: newSplits); } @override - List get props => [id]; -} \ No newline at end of file + List get props => [ + id, + groupId, + description, + amount, + currency, + amountInEur, + category, + paidById, + paidByName, + date, + createdAt, + editedAt, + isEdited, + isArchived, + receiptUrl, + splits, + ]; +} diff --git a/lib/models/group_balance.dart b/lib/models/group_balance.dart index 1d121ec..ff8bed3 100644 --- a/lib/models/group_balance.dart +++ b/lib/models/group_balance.dart @@ -22,12 +22,22 @@ class GroupBalance extends Equatable { factory GroupBalance.fromMap(Map map) { return GroupBalance( groupId: map['groupId'] ?? '', - userBalances: (map['userBalances'] as List?) - ?.map((userBalance) => UserBalance.fromMap(userBalance as Map)) - .toList() ?? [], - settlements: (map['settlements'] as List?) - ?.map((settlement) => Settlement.fromMap(settlement as Map)) - .toList() ?? [], + userBalances: + (map['userBalances'] as List?) + ?.map( + (userBalance) => + UserBalance.fromMap(userBalance as Map), + ) + .toList() ?? + [], + settlements: + (map['settlements'] as List?) + ?.map( + (settlement) => + Settlement.fromMap(settlement as Map), + ) + .toList() ?? + [], totalExpenses: (map['totalExpenses'] as num?)?.toDouble() ?? 0.0, calculatedAt: _parseDateTime(map['calculatedAt']), ); @@ -37,8 +47,12 @@ class GroupBalance extends Equatable { Map toMap() { return { 'groupId': groupId, - 'userBalances': userBalances.map((userBalance) => userBalance.toMap()).toList(), - 'settlements': settlements.map((settlement) => settlement.toMap()).toList(), + 'userBalances': userBalances + .map((userBalance) => userBalance.toMap()) + .toList(), + 'settlements': settlements + .map((settlement) => settlement.toMap()) + .toList(), 'totalExpenses': totalExpenses, 'calculatedAt': Timestamp.fromDate(calculatedAt), }; @@ -71,16 +85,20 @@ class GroupBalance extends Equatable { } // Méthodes utilitaires pour la logique métier - bool get hasUnbalancedUsers => userBalances.any((balance) => !balance.isBalanced); - + bool get hasUnbalancedUsers => + userBalances.any((balance) => !balance.isBalanced); + bool get hasSettlements => settlements.isNotEmpty; - - double get totalSettlementAmount => settlements.fold(0.0, (sum, settlement) => sum + settlement.amount); - - List get creditors => userBalances.where((b) => b.shouldReceive).toList(); - - List get debtors => userBalances.where((b) => b.shouldPay).toList(); - + + double get totalSettlementAmount => + settlements.fold(0.0, (total, settlement) => total + settlement.amount); + + List get creditors => + userBalances.where((b) => b.shouldReceive).toList(); + + List get debtors => + userBalances.where((b) => b.shouldPay).toList(); + int get participantCount => userBalances.length; @override @@ -90,4 +108,4 @@ class GroupBalance extends Equatable { String toString() { return 'GroupBalance(groupId: $groupId, totalExpenses: $totalExpenses, participantCount: $participantCount, calculatedAt: $calculatedAt)'; } -} \ No newline at end of file +} diff --git a/lib/repositories/activity_repository.dart b/lib/repositories/activity_repository.dart index f4c035d..83998aa 100644 --- a/lib/repositories/activity_repository.dart +++ b/lib/repositories/activity_repository.dart @@ -16,7 +16,10 @@ class ActivityRepository { /// Ajoute une nouvelle activité Future addActivity(Activity activity) async { try { - print('ActivityRepository: Ajout d\'une activité: ${activity.name}'); + _errorService.logInfo( + 'ActivityRepository', + 'Ajout d\'une activité: ${activity.name}', + ); final docRef = await _firestore .collection(_collection) @@ -25,10 +28,16 @@ class ActivityRepository { // Mettre à jour l'activité avec l'ID généré await docRef.update({'id': docRef.id}); - print('ActivityRepository: Activité ajoutée avec ID: ${docRef.id}'); + _errorService.logSuccess( + 'ActivityRepository', + 'Activité ajoutée avec ID: ${docRef.id}', + ); return docRef.id; } catch (e) { - print('ActivityRepository: Erreur lors de l\'ajout: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur lors de l\'ajout: $e', + ); _errorService.logError( 'activity_repository', 'Erreur ajout activité: $e', @@ -40,8 +49,9 @@ class ActivityRepository { /// Récupère toutes les activités d'un voyage Future> getActivitiesByTrip(String tripId) async { try { - print( - 'ActivityRepository: Récupération des activités pour le voyage: $tripId', + _errorService.logInfo( + 'ActivityRepository', + 'Récupération des activités pour le voyage: $tripId', ); // Modifié pour éviter l'erreur d'index composite @@ -58,10 +68,16 @@ class ActivityRepository { // Tri en mémoire par date de mise à jour (plus récent en premier) activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - print('ActivityRepository: ${activities.length} activités trouvées'); + _errorService.logInfo( + 'ActivityRepository', + '${activities.length} activités trouvées', + ); return activities; } catch (e) { - print('ActivityRepository: Erreur lors de la récupération: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur lors de la récupération: $e', + ); _errorService.logError( 'activity_repository', 'Erreur récupération activités: $e', @@ -89,7 +105,10 @@ class ActivityRepository { return null; } catch (e) { - print('ActivityRepository: Erreur récupération activité: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur récupération activité: $e', + ); _errorService.logError( 'activity_repository', 'Erreur récupération activité: $e', @@ -101,17 +120,26 @@ class ActivityRepository { /// Met à jour une activité Future updateActivity(Activity activity) async { try { - print('ActivityRepository: Mise à jour de l\'activité: ${activity.id}'); + _errorService.logInfo( + 'ActivityRepository', + 'Mise à jour de l\'activité: ${activity.id}', + ); await _firestore .collection(_collection) .doc(activity.id) .update(activity.copyWith(updatedAt: DateTime.now()).toMap()); - print('ActivityRepository: Activité mise à jour avec succès'); + _errorService.logSuccess( + 'ActivityRepository', + 'Activité mise à jour avec succès', + ); return true; } catch (e) { - print('ActivityRepository: Erreur lors de la mise à jour: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur lors de la mise à jour: $e', + ); _errorService.logError( 'activity_repository', 'Erreur mise à jour activité: $e', @@ -123,14 +151,23 @@ class ActivityRepository { /// Supprime une activité Future deleteActivity(String activityId) async { try { - print('ActivityRepository: Suppression de l\'activité: $activityId'); + _errorService.logInfo( + 'ActivityRepository', + 'Suppression de l\'activité: $activityId', + ); await _firestore.collection(_collection).doc(activityId).delete(); - print('ActivityRepository: Activité supprimée avec succès'); + _errorService.logSuccess( + 'ActivityRepository', + 'Activité supprimée avec succès', + ); return true; } catch (e) { - print('ActivityRepository: Erreur lors de la suppression: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur lors de la suppression: $e', + ); _errorService.logError( 'activity_repository', 'Erreur suppression activité: $e', @@ -148,7 +185,7 @@ class ActivityRepository { try { // Validation des paramètres if (activityId.isEmpty) { - print('ActivityRepository: ID d\'activité vide'); + _errorService.logError('ActivityRepository', 'ID d\'activité vide'); _errorService.logError( 'activity_repository', 'ID d\'activité vide pour le vote', @@ -157,7 +194,7 @@ class ActivityRepository { } if (userId.isEmpty) { - print('ActivityRepository: ID d\'utilisateur vide'); + _errorService.logError('ActivityRepository', 'ID d\'utilisateur vide'); _errorService.logError( 'activity_repository', 'ID d\'utilisateur vide pour le vote', @@ -165,7 +202,10 @@ class ActivityRepository { return false; } - print('ActivityRepository: Vote pour l\'activité $activityId: $vote'); + _errorService.logInfo( + 'ActivityRepository', + 'Vote pour l\'activité $activityId: $vote', + ); // vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote final activityRef = _firestore.collection(_collection).doc(activityId); @@ -194,10 +234,13 @@ class ActivityRepository { }); }); - print('ActivityRepository: Vote enregistré avec succès'); + _errorService.logSuccess( + 'ActivityRepository', + 'Vote enregistré avec succès', + ); return true; } catch (e) { - print('ActivityRepository: Erreur lors du vote: $e'); + _errorService.logError('ActivityRepository', 'Erreur lors du vote: $e'); _errorService.logError('activity_repository', 'Erreur vote: $e'); return false; } @@ -221,7 +264,10 @@ class ActivityRepository { return activities; }); } catch (e) { - print('ActivityRepository: Erreur stream activités: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur stream activités: $e', + ); _errorService.logError( 'activity_repository', 'Erreur stream activités: $e', @@ -233,8 +279,9 @@ class ActivityRepository { /// Ajoute plusieurs activités en lot Future> addActivitiesBatch(List activities) async { try { - print( - 'ActivityRepository: Ajout en lot de ${activities.length} activités', + _errorService.logInfo( + 'ActivityRepository', + 'Ajout en lot de ${activities.length} activités', ); final batch = _firestore.batch(); @@ -249,10 +296,13 @@ class ActivityRepository { await batch.commit(); - print('ActivityRepository: ${addedIds.length} activités ajoutées en lot'); + _errorService.logSuccess( + 'ActivityRepository', + '${addedIds.length} activités ajoutées en lot', + ); return addedIds; } catch (e) { - print('ActivityRepository: Erreur ajout en lot: $e'); + _errorService.logError('ActivityRepository', 'Erreur ajout en lot: $e'); _errorService.logError('activity_repository', 'Erreur ajout en lot: $e'); return []; } @@ -264,8 +314,9 @@ class ActivityRepository { String category, ) async { try { - print( - 'ActivityRepository: Recherche par catégorie: $category pour le voyage: $tripId', + _errorService.logInfo( + 'ActivityRepository', + 'Recherche par catégorie: $category pour le voyage: $tripId', ); // Récupérer toutes les activités du voyage puis filtrer en mémoire @@ -284,7 +335,10 @@ class ActivityRepository { return activities; } catch (e) { - print('ActivityRepository: Erreur recherche par catégorie: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur recherche par catégorie: $e', + ); _errorService.logError( 'activity_repository', 'Erreur recherche par catégorie: $e', @@ -315,7 +369,10 @@ class ActivityRepository { return activities.take(limit).toList(); } catch (e) { - print('ActivityRepository: Erreur activités top rated: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur activités top rated: $e', + ); _errorService.logError('activity_repository', 'Erreur top rated: $e'); return []; } @@ -337,7 +394,10 @@ class ActivityRepository { return null; } catch (e) { - print('ActivityRepository: Erreur recherche activité existante: $e'); + _errorService.logError( + 'ActivityRepository', + 'Erreur recherche activité existante: $e', + ); return null; } } diff --git a/lib/repositories/group_repository.dart b/lib/repositories/group_repository.dart index ed81502..bfbba4e 100644 --- a/lib/repositories/group_repository.dart +++ b/lib/repositories/group_repository.dart @@ -117,10 +117,16 @@ class GroupRepository { if (memberIds.isNotEmpty) { await _groupsCollection.doc(groupId).update({'memberIds': memberIds}); - print('Migration réussie pour le groupe $groupId'); + _errorService.logSuccess( + 'GroupRepository', + 'Migration réussie pour le groupe $groupId', + ); } } catch (e) { - print('Erreur de migration pour le groupe $groupId: $e'); + _errorService.logError( + 'GroupRepository', + 'Erreur de migration pour le groupe $groupId: $e', + ); } } diff --git a/lib/services/activity_places_service.dart b/lib/services/activity_places_service.dart index b028fa0..3f0b785 100644 --- a/lib/services/activity_places_service.dart +++ b/lib/services/activity_places_service.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/activity.dart'; import '../services/error_service.dart'; +import '../services/logger_service.dart'; /// Service pour rechercher des activités touristiques via Google Places API class ActivityPlacesService { @@ -24,7 +25,7 @@ class ActivityPlacesService { int offset = 0, }) async { try { - print( + LoggerService.info( 'ActivityPlacesService: Recherche d\'activités pour: $destination (max: $maxResults, offset: $offset)', ); @@ -69,7 +70,7 @@ class ActivityPlacesService { final uniqueActivities = _removeDuplicates(allActivities); uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0)); - print( + LoggerService.info( 'ActivityPlacesService: ${uniqueActivities.length} activités trouvées au total', ); @@ -81,20 +82,22 @@ class ActivityPlacesService { ); if (startIndex >= uniqueActivities.length) { - print( + LoggerService.info( 'ActivityPlacesService: Offset $startIndex dépasse le nombre total (${uniqueActivities.length})', ); return []; } final paginatedResults = uniqueActivities.sublist(startIndex, endIndex); - print( + LoggerService.info( 'ActivityPlacesService: Retour de ${paginatedResults.length} activités (offset: $offset, max: $maxResults)', ); return paginatedResults; } catch (e) { - print('ActivityPlacesService: Erreur lors de la recherche: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur lors de la recherche: $e', + ); _errorService.logError('activity_places_service', e); return []; } @@ -105,7 +108,9 @@ class ActivityPlacesService { try { // Vérifier que la clé API est configurée if (_apiKey.isEmpty) { - print('ActivityPlacesService: Clé API Google Maps manquante'); + LoggerService.error( + 'ActivityPlacesService: Clé API Google Maps manquante', + ); throw Exception('Clé API Google Maps non configurée'); } @@ -113,16 +118,20 @@ class ActivityPlacesService { final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey'; - print('ActivityPlacesService: Géocodage de "$destination"'); - print('ActivityPlacesService: URL = $url'); + LoggerService.info('ActivityPlacesService: Géocodage de "$destination"'); + LoggerService.info('ActivityPlacesService: URL = $url'); final response = await http.get(Uri.parse(url)); - print('ActivityPlacesService: Status code = ${response.statusCode}'); + LoggerService.info( + 'ActivityPlacesService: Status code = ${response.statusCode}', + ); if (response.statusCode == 200) { final data = json.decode(response.body); - print('ActivityPlacesService: Réponse géocodage = ${data['status']}'); + LoggerService.info( + 'ActivityPlacesService: Réponse géocodage = ${data['status']}', + ); if (data['status'] == 'OK' && data['results'].isNotEmpty) { final location = data['results'][0]['geometry']['location']; @@ -130,10 +139,12 @@ class ActivityPlacesService { 'lat': location['lat'].toDouble(), 'lng': location['lng'].toDouble(), }; - print('ActivityPlacesService: Coordonnées trouvées = $coordinates'); + LoggerService.info( + 'ActivityPlacesService: Coordonnées trouvées = $coordinates', + ); return coordinates; } else { - print( + LoggerService.error( 'ActivityPlacesService: Erreur API = ${data['error_message'] ?? data['status']}', ); if (data['status'] == 'REQUEST_DENIED') { @@ -154,7 +165,7 @@ class ActivityPlacesService { throw Exception('Erreur HTTP ${response.statusCode}'); } } catch (e) { - print('ActivityPlacesService: Erreur géocodage: $e'); + LoggerService.error('ActivityPlacesService: Erreur géocodage: $e'); rethrow; // Rethrow pour permettre la gestion d'erreur en amont } } @@ -194,7 +205,9 @@ class ActivityPlacesService { activities.add(activity); } } catch (e) { - print('ActivityPlacesService: Erreur conversion place: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur conversion place: $e', + ); } } @@ -204,7 +217,9 @@ class ActivityPlacesService { return []; } catch (e) { - print('ActivityPlacesService: Erreur recherche par catégorie: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur recherche par catégorie: $e', + ); return []; } } @@ -260,7 +275,7 @@ class ActivityPlacesService { updatedAt: DateTime.now(), ); } catch (e) { - print('ActivityPlacesService: Erreur conversion place: $e'); + LoggerService.error('ActivityPlacesService: Erreur conversion place: $e'); return null; } } @@ -285,7 +300,9 @@ class ActivityPlacesService { return null; } catch (e) { - print('ActivityPlacesService: Erreur récupération détails: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur récupération détails: $e', + ); return null; } } @@ -326,7 +343,7 @@ class ActivityPlacesService { int radius = 5000, }) async { try { - print( + LoggerService.info( 'ActivityPlacesService: Recherche textuelle: $query à $destination', ); @@ -364,7 +381,9 @@ class ActivityPlacesService { activities.add(activity); } } catch (e) { - print('ActivityPlacesService: Erreur conversion place: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur conversion place: $e', + ); } } @@ -374,7 +393,9 @@ class ActivityPlacesService { return []; } catch (e) { - print('ActivityPlacesService: Erreur recherche textuelle: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur recherche textuelle: $e', + ); return []; } } @@ -423,11 +444,11 @@ class ActivityPlacesService { if (latitude != null && longitude != null) { lat = latitude; lng = longitude; - print( + LoggerService.info( 'ActivityPlacesService: Utilisation des coordonnées pré-géolocalisées: $lat, $lng', ); } else if (destination != null) { - print( + LoggerService.info( 'ActivityPlacesService: Géolocalisation de la destination: $destination', ); final coordinates = await _geocodeDestination(destination); @@ -437,7 +458,7 @@ class ActivityPlacesService { throw Exception('Destination ou coordonnées requises'); } - print( + LoggerService.info( 'ActivityPlacesService: Recherche paginée aux coordonnées: $lat, $lng (page: ${nextPageToken ?? "première"})', ); @@ -464,7 +485,9 @@ class ActivityPlacesService { ); } } catch (e) { - print('ActivityPlacesService: Erreur recherche paginée: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur recherche paginée: $e', + ); _errorService.logError('activity_places_service', e); return { 'activities': [], @@ -519,7 +542,9 @@ class ActivityPlacesService { activities.add(activity); } } catch (e) { - print('ActivityPlacesService: Erreur conversion place: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur conversion place: $e', + ); } } @@ -537,7 +562,9 @@ class ActivityPlacesService { 'hasMoreData': false, }; } catch (e) { - print('ActivityPlacesService: Erreur recherche catégorie paginée: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur recherche catégorie paginée: $e', + ); return { 'activities': [], 'nextPageToken': null, @@ -595,7 +622,9 @@ class ActivityPlacesService { activities.add(activity); } } catch (e) { - print('ActivityPlacesService: Erreur conversion place: $e'); + LoggerService.error( + 'ActivityPlacesService: Erreur conversion place: $e', + ); } } @@ -613,7 +642,7 @@ class ActivityPlacesService { 'hasMoreData': false, }; } catch (e) { - print( + LoggerService.error( 'ActivityPlacesService: Erreur recherche toutes catégories paginée: $e', ); return { diff --git a/lib/services/balance_service.dart b/lib/services/balance_service.dart index dad46ea..48d0c75 100644 --- a/lib/services/balance_service.dart +++ b/lib/services/balance_service.dart @@ -32,6 +32,7 @@ /// - [Settlement] for individual payment recommendations /// - [UserBalance] for per-user balance information library; + import '../models/group_balance.dart'; import '../models/expense.dart'; import '../models/group_statistics.dart'; @@ -59,7 +60,10 @@ class BalanceService { try { return await _balanceRepository.calculateGroupBalance(groupId); } catch (e) { - _errorService.logError('BalanceService', 'Erreur calcul balance groupe: $e'); + _errorService.logError( + 'BalanceService', + 'Erreur calcul balance groupe: $e', + ); rethrow; } } @@ -80,7 +84,9 @@ class BalanceService { /// Stream de la balance en temps réel Stream getGroupBalanceStream(String groupId) { - return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async { + return _expenseRepository.getExpensesStream(groupId).asyncMap(( + expenses, + ) async { try { final userBalances = calculateUserBalances(expenses); final settlements = optimizeSettlements(userBalances); @@ -145,7 +151,7 @@ class BalanceService { /// Optimiser les règlements (algorithme avancé) List optimizeSettlements(List balances) { final settlements = []; - + // Filtrer les utilisateurs avec une balance significative (> 0.01€) final creditors = balances .where((b) => b.shouldReceive && b.balance > 0.01) @@ -164,10 +170,10 @@ class BalanceService { // Utiliser des copies mutables pour les calculs final creditorsRemaining = Map.fromEntries( - creditors.map((c) => MapEntry(c.userId, c.balance)) + creditors.map((c) => MapEntry(c.userId, c.balance)), ); final debtorsRemaining = Map.fromEntries( - debtors.map((d) => MapEntry(d.userId, -d.balance)) + debtors.map((d) => MapEntry(d.userId, -d.balance)), ); // Algorithme glouton optimisé @@ -185,16 +191,19 @@ class BalanceService { ); if (settlementAmount > 0.01) { - settlements.add(Settlement( - fromUserId: debtor.userId, - fromUserName: debtor.userName, - toUserId: creditor.userId, - toUserName: creditor.userName, - amount: settlementAmount, - )); + 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; + creditorsRemaining[creditor.userId] = + creditRemaining - settlementAmount; debtorsRemaining[debtor.userId] = debtRemaining - settlementAmount; } } @@ -204,7 +213,10 @@ class BalanceService { } /// Calculer le montant optimal pour un règlement - double _calculateOptimalSettlementAmount(double creditAmount, double debtAmount) { + 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; @@ -213,36 +225,50 @@ class BalanceService { /// Valider les règlements calculés List _validateSettlements(List settlements) { // Supprimer les règlements trop petits - final validSettlements = settlements - .where((s) => s.amount > 0.01) - .toList(); + 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)}€'); + 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) { + 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 + + // On peut utiliser optimizeSettlements pour avoir la réponse précise + // Cependant, cela nécessite d'avoir les dépenses. + // Comme cette méthode est synchrone et ne prend pas les dépenses en entrée, + // elle est difficile à implémenter correctement sans changer sa signature. + // Pour l'instant, on retourne 0.0 car elle n'est pas utilisée. + return 0.0; } /// Analyser les tendances de dépenses par catégorie Map analyzeCategorySpending(List expenses) { final categoryTotals = {}; - + for (final expense in expenses) { if (expense.isArchived) continue; - + final categoryName = expense.category.displayName; - categoryTotals[categoryName] = (categoryTotals[categoryName] ?? 0) + expense.amountInEur; + categoryTotals[categoryName] = + (categoryTotals[categoryName] ?? 0) + expense.amountInEur; } - + return categoryTotals; } @@ -253,9 +279,12 @@ class BalanceService { } final nonArchivedExpenses = expenses.where((e) => !e.isArchived).toList(); - final totalAmount = nonArchivedExpenses.fold(0.0, (sum, e) => sum + e.amountInEur); + 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) @@ -279,11 +308,14 @@ class BalanceService { 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é'); + + _errorService.logSuccess( + 'BalanceService', + 'Règlement marqué comme effectué', + ); } catch (e) { _errorService.logError('BalanceService', 'Erreur mark settlement: $e'); rethrow; @@ -312,4 +344,4 @@ class _UserBalanceCalculator { balance: _totalPaid - _totalOwed, ); } -} \ No newline at end of file +} diff --git a/lib/services/error_service.dart b/lib/services/error_service.dart index 5d26da2..15cdbbe 100644 --- a/lib/services/error_service.dart +++ b/lib/services/error_service.dart @@ -1,32 +1,33 @@ import 'package:flutter/material.dart'; import '../components/error/error_content.dart'; +import 'logger_service.dart'; /// Service for handling application errors and user notifications. -/// +/// /// This singleton service provides centralized error handling capabilities /// including displaying error dialogs, snackbars, and logging errors for /// debugging purposes. It uses a global navigator key to show notifications /// from anywhere in the application. class ErrorService { static final ErrorService _instance = ErrorService._internal(); - + /// Factory constructor that returns the singleton instance. factory ErrorService() => _instance; - + /// Private constructor for singleton pattern. ErrorService._internal(); /// Global navigator key for accessing context from anywhere in the app. - /// + /// /// This key should be assigned to the MaterialApp's navigatorKey property /// to enable error notifications from any part of the application. static GlobalKey navigatorKey = GlobalKey(); /// Displays an error message in a dialog. - /// + /// /// Shows a modal dialog with the error message and optional retry functionality. /// The dialog appearance can be customized with different icons and colors. - /// + /// /// [message] - The error message to display /// [title] - The dialog title (defaults to 'Error') /// [onRetry] - Optional callback for retry functionality @@ -53,10 +54,10 @@ class ErrorService { } /// Displays an error or success message in a snackbar. - /// + /// /// Shows a floating snackbar at the bottom of the screen with the message. /// The appearance changes based on whether it's an error or success message. - /// + /// /// [message] - The message to display /// [onRetry] - Optional callback for retry functionality /// [isError] - Whether this is an error (true) or success (false) message @@ -89,36 +90,35 @@ class ErrorService { } /// Logs error messages to the console during development. - /// + /// /// Formats and displays error information including source, error message, /// and optional stack trace in a visually distinct format. - /// + /// /// [source] - The source or location where the error occurred /// [error] - The error object or message /// [stackTrace] - Optional stack trace for debugging void logError(String source, dynamic error, [StackTrace? stackTrace]) { - print('═══════════════════════════════════'); - print('❌ ERROR in $source'); - print('Message: $error'); - if (stackTrace != null) { - print('StackTrace: $stackTrace'); - } - print('═══════════════════════════════════'); + LoggerService.error( + '❌ ERROR in $source\nMessage: $error', + name: source, + error: error, + stackTrace: stackTrace, + ); } /// Logs informational messages to the console during development. - /// + /// /// [source] - The source or location of the information /// [message] - The informational message void logInfo(String source, String message) { - print('ℹ️ [$source] $message'); + LoggerService.info('ℹ️ $message', name: source); } /// Logs success messages to the console during development. - /// + /// /// [source] - The source or location of the success /// [message] - The success message void logSuccess(String source, String message) { - print('✅ [$source] $message'); + LoggerService.info('✅ $message', name: source); } -} \ No newline at end of file +} diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart new file mode 100644 index 0000000..5e06465 --- /dev/null +++ b/lib/services/logger_service.dart @@ -0,0 +1,30 @@ +import 'dart:developer' as developer; + +class LoggerService { + static void log(String message, {String name = 'App'}) { + developer.log(message, name: name); + } + + static void error( + String message, { + String name = 'App', + Object? error, + StackTrace? stackTrace, + }) { + developer.log( + message, + name: name, + error: error, + stackTrace: stackTrace, + level: 1000, + ); + } + + static void info(String message, {String name = 'App'}) { + developer.log(message, name: name, level: 800); + } + + static void warning(String message, {String name = 'App'}) { + developer.log(message, name: name, level: 900); + } +} diff --git a/lib/services/place_image_service.dart b/lib/services/place_image_service.dart index 97478c5..80c73da 100644 --- a/lib/services/place_image_service.dart +++ b/lib/services/place_image_service.dart @@ -11,7 +11,6 @@ class PlaceImageService { /// Récupère l'URL de l'image d'un lieu depuis Google Places API Future getPlaceImageUrl(String location) async { - try { // ÉTAPE 1: Vérifier d'abord si une image existe déjà dans le Storage final existingUrl = await _checkExistingImage(location); @@ -19,26 +18,25 @@ class PlaceImageService { return existingUrl; } - if (_apiKey.isEmpty) { - _errorService.logError('PlaceImageService', 'Google Maps API key manquante'); + _errorService.logError( + 'PlaceImageService', + 'Google Maps API key manquante', + ); return null; } // ÉTAPE 2: Recherche via Google Places API seulement si aucune image n'existe final searchTerms = _generateSearchTerms(location); - + for (final searchTerm in searchTerms) { - // 1. Rechercher le lieu final placeId = await _getPlaceIdForTerm(searchTerm); if (placeId == null) continue; - // 2. Récupérer les détails du lieu avec les photos final photoReference = await _getPhotoReference(placeId); if (photoReference == null) continue; - // 3. Télécharger et sauvegarder l'image (seulement si pas d'image existante) final imageUrl = await _downloadAndSaveImage(photoReference, location); @@ -46,11 +44,13 @@ class PlaceImageService { return imageUrl; } } - - return null; + return null; } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors de la récupération de l\'image: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la récupération de l\'image: $e', + ); return null; } } @@ -58,11 +58,11 @@ class PlaceImageService { /// Génère différents termes de recherche pour optimiser les résultats List _generateSearchTerms(String location) { final terms = []; - + // Ajouter des termes spécifiques pour les villes connues final citySpecificTerms = _getCitySpecificTerms(location.toLowerCase()); terms.addAll(citySpecificTerms); - + // Termes génériques avec attractions terms.addAll([ '$location attractions touristiques monuments', @@ -74,14 +74,14 @@ class PlaceImageService { '$location skyline', location, // Terme original en dernier ]); - + return terms; } /// Retourne des termes spécifiques pour des villes connues List _getCitySpecificTerms(String location) { final specific = []; - + if (location.contains('paris')) { specific.addAll([ 'Tour Eiffel Paris', @@ -120,20 +120,23 @@ class PlaceImageService { 'Tokyo Skytree', ]); } - + return specific; } /// Recherche un place ID pour un terme spécifique Future _getPlaceIdForTerm(String searchTerm) async { // Essayer d'abord avec les attractions touristiques - String? placeId = await _searchPlaceWithType(searchTerm, 'tourist_attraction'); + String? placeId = await _searchPlaceWithType( + searchTerm, + 'tourist_attraction', + ); if (placeId != null) return placeId; - + // Puis avec les points d'intérêt placeId = await _searchPlaceWithType(searchTerm, 'point_of_interest'); if (placeId != null) return placeId; - + // Enfin recherche générale return await _searchPlaceGeneral(searchTerm); } @@ -146,14 +149,14 @@ class PlaceImageService { '?query=${Uri.encodeComponent('$location attractions monuments')}' '&type=$type' '&fields=place_id,name,types,rating' - '&key=$_apiKey' + '&key=$_apiKey', ); final response = await http.get(url); - + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK' && data['results'].isNotEmpty) { // Prioriser les résultats avec des ratings élevés final results = data['results'] as List; @@ -162,7 +165,7 @@ class PlaceImageService { final bRating = b['rating'] ?? 0.0; return bRating.compareTo(aRating); }); - + return results.first['place_id']; } } @@ -180,14 +183,14 @@ class PlaceImageService { '?input=${Uri.encodeComponent(location)}' '&inputtype=textquery' '&fields=place_id' - '&key=$_apiKey' + '&key=$_apiKey', ); final response = await http.get(url); - + if (response.statusCode == 200) { final data = json.decode(response.body); - + if (data['status'] == 'OK' && data['candidates'].isNotEmpty) { return data['candidates'][0]['place_id']; } @@ -205,24 +208,23 @@ class PlaceImageService { 'https://maps.googleapis.com/maps/api/place/details/json' '?place_id=$placeId' '&fields=photos' - '&key=$_apiKey' + '&key=$_apiKey', ); final response = await http.get(url); - + if (response.statusCode == 200) { final data = json.decode(response.body); - - if (data['status'] == 'OK' && - data['result'] != null && - data['result']['photos'] != null && + + if (data['status'] == 'OK' && + data['result'] != null && + data['result']['photos'] != null && data['result']['photos'].isNotEmpty) { - final photos = data['result']['photos'] as List; - + // Trier les photos pour obtenir les meilleures final sortedPhotos = _sortPhotosByQuality(photos); - + if (sortedPhotos.isNotEmpty) { return sortedPhotos.first['photo_reference']; } @@ -230,7 +232,10 @@ class PlaceImageService { } return null; } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors de la récupération de la référence photo: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la récupération de la référence photo: $e', + ); return null; } } @@ -238,64 +243,68 @@ class PlaceImageService { /// Trie les photos par qualité (largeur/hauteur et popularité) List> _sortPhotosByQuality(List photos) { final photoList = photos.cast>(); - + photoList.sort((a, b) { // Priorité 1: Photos horizontales (largeur > hauteur) final aWidth = a['width'] ?? 0; final aHeight = a['height'] ?? 0; final bWidth = b['width'] ?? 0; final bHeight = b['height'] ?? 0; - + final aIsHorizontal = aWidth > aHeight; final bIsHorizontal = bWidth > bHeight; - + if (aIsHorizontal && !bIsHorizontal) return -1; if (!aIsHorizontal && bIsHorizontal) return 1; - + // Priorité 2: Résolution plus élevée final aResolution = aWidth * aHeight; final bResolution = bWidth * bHeight; - + if (aResolution != bResolution) { return bResolution.compareTo(aResolution); } - + // Priorité 3: Ratio d'aspect optimal pour paysage (1.5-2.0) final aRatio = aWidth > 0 ? aWidth / aHeight : 0; final bRatio = bWidth > 0 ? bWidth / bHeight : 0; - + final idealRatio = 1.7; // Ratio 16:9 environ final aDiff = (aRatio - idealRatio).abs(); final bDiff = (bRatio - idealRatio).abs(); - + return aDiff.compareTo(bDiff); }); - + return photoList; } /// Télécharge l'image et la sauvegarde dans Firebase Storage - Future _downloadAndSaveImage(String photoReference, String location) async { + Future _downloadAndSaveImage( + String photoReference, + String location, + ) async { try { // URL pour télécharger l'image en haute qualité et format horizontal - final imageUrl = 'https://maps.googleapis.com/maps/api/place/photo' - '?maxwidth=1200' // Augmenté pour une meilleure qualité - '&maxheight=800' // Ratio horizontal ~1.5:1 + final imageUrl = + 'https://maps.googleapis.com/maps/api/place/photo' + '?maxwidth=1200' // Augmenté pour une meilleure qualité + '&maxheight=800' // Ratio horizontal ~1.5:1 '&photo_reference=$photoReference' '&key=$_apiKey'; - // Télécharger l'image final response = await http.get(Uri.parse(imageUrl)); - + if (response.statusCode == 200) { // Créer un nom de fichier unique basé sur la localisation normalisée final normalizedLocation = _normalizeLocationName(location); - final fileName = '${normalizedLocation}_${DateTime.now().millisecondsSinceEpoch}.jpg'; - + final fileName = + '${normalizedLocation}_${DateTime.now().millisecondsSinceEpoch}.jpg'; + // Référence vers Firebase Storage final storageRef = _storage.ref().child('trip_images/$fileName'); - + // Upload de l'image avec métadonnées final uploadTask = await storageRef.putData( response.bodyBytes, @@ -309,15 +318,17 @@ class PlaceImageService { }, ), ); - + // Récupérer l'URL de téléchargement final downloadUrl = await uploadTask.ref.getDownloadURL(); return downloadUrl; - } else { - } + } else {} return null; } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors du téléchargement/sauvegarde: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors du téléchargement/sauvegarde: $e', + ); return null; } } @@ -326,45 +337,52 @@ class PlaceImageService { Future _checkExistingImage(String location) async { try { final normalizedLocation = _normalizeLocationName(location); - + final listResult = await _storage.ref('trip_images').listAll(); - + for (final item in listResult.items) { try { final metadata = await item.getMetadata(); - final storedNormalizedLocation = metadata.customMetadata?['normalizedLocation']; + final storedNormalizedLocation = + metadata.customMetadata?['normalizedLocation']; final storedLocation = metadata.customMetadata?['location']; - + // Méthode 1: Vérifier avec la location normalisée (nouvelles images) - if (storedNormalizedLocation != null && storedNormalizedLocation == normalizedLocation) { + if (storedNormalizedLocation != null && + storedNormalizedLocation == normalizedLocation) { final url = await item.getDownloadURL(); return url; } - + // Méthode 2: Vérifier avec la location originale normalisée (anciennes images) if (storedLocation != null) { - final storedLocationNormalized = _normalizeLocationName(storedLocation); + final storedLocationNormalized = _normalizeLocationName( + storedLocation, + ); if (storedLocationNormalized == normalizedLocation) { final url = await item.getDownloadURL(); return url; } } - } catch (e) { // Méthode 3: Essayer de deviner depuis le nom du fichier (fallback) final fileName = item.name; - if (fileName.toLowerCase().contains(normalizedLocation.toLowerCase())) { + if (fileName.toLowerCase().contains( + normalizedLocation.toLowerCase(), + )) { try { final url = await item.getDownloadURL(); return url; } catch (urlError) { - _errorService.logError('PlaceImageService', 'Erreur lors de la récupération de l\'URL: $urlError'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la récupération de l\'URL: $urlError', + ); } } - } } - + return null; } catch (e) { return null; @@ -384,23 +402,26 @@ class PlaceImageService { Future cleanupUnusedImages(List usedImageUrls) async { try { final listResult = await _storage.ref('trip_images').listAll(); - int deletedCount = 0; - + for (final item in listResult.items) { try { final url = await item.getDownloadURL(); - + if (!usedImageUrls.contains(url)) { await item.delete(); - deletedCount++; } } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors du nettoyage: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors du nettoyage: $e', + ); } } - } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors du nettoyage: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors du nettoyage: $e', + ); } } @@ -408,18 +429,19 @@ class PlaceImageService { Future cleanupDuplicateImages() async { try { final listResult = await _storage.ref('trip_images').listAll(); - + // Grouper les images par location normalisée final Map> locationGroups = {}; - + for (final item in listResult.items) { String locationKey = 'unknown'; - + try { final metadata = await item.getMetadata(); - final storedNormalizedLocation = metadata.customMetadata?['normalizedLocation']; + final storedNormalizedLocation = + metadata.customMetadata?['normalizedLocation']; final storedLocation = metadata.customMetadata?['location']; - + if (storedNormalizedLocation != null) { locationKey = storedNormalizedLocation; } else if (storedLocation != null) { @@ -440,43 +462,44 @@ class PlaceImageService { locationKey = parts.take(parts.length - 1).join('_'); } } - + if (!locationGroups.containsKey(locationKey)) { locationGroups[locationKey] = []; } locationGroups[locationKey]!.add(item); } - + // Supprimer les doublons (garder le plus récent) - int deletedCount = 0; + for (final entry in locationGroups.entries) { - final location = entry.key; final images = entry.value; - + if (images.length > 1) { - // Trier par timestamp (garder le plus récent) images.sort((a, b) { final aTimestamp = _extractTimestampFromName(a.name); final bTimestamp = _extractTimestampFromName(b.name); return bTimestamp.compareTo(aTimestamp); // Plus récent en premier }); - + // Supprimer tous sauf le premier (plus récent) for (int i = 1; i < images.length; i++) { try { await images[i].delete(); - deletedCount++; } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors de la suppression du doublon: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la suppression du doublon: $e', + ); } } - } } - } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors du nettoyage des doublons: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors du nettoyage des doublons: $e', + ); } } @@ -499,7 +522,10 @@ class PlaceImageService { } return null; } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors de la recherche d\'image existante: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la recherche d\'image existante: $e', + ); return null; } } @@ -510,7 +536,10 @@ class PlaceImageService { final ref = _storage.refFromURL(imageUrl); await ref.delete(); } catch (e) { - _errorService.logError('PlaceImageService', 'Erreur lors de la suppression de l\'image: $e'); + _errorService.logError( + 'PlaceImageService', + 'Erreur lors de la suppression de l\'image: $e', + ); } } -} \ No newline at end of file +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a9d587e..e691c62 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -14,17 +14,18 @@ /// Example usage: /// ```dart /// final storageService = StorageService(); -/// +/// /// // Upload a receipt image /// final receiptUrl = await storageService.uploadReceiptImage(groupId, imageFile); -/// +/// /// // Upload a profile image /// final profileUrl = await storageService.uploadProfileImage(userId, imageFile); -/// +/// /// // Delete a file /// await storageService.deleteFile(fileUrl); /// ``` library; + import 'dart:io'; import 'dart:typed_data'; import 'package:firebase_storage/firebase_storage.dart'; @@ -36,34 +37,32 @@ import 'error_service.dart'; class StorageService { /// Firebase Storage instance for file operations final FirebaseStorage _storage; - + /// Service for error handling and logging final ErrorService _errorService; /// Constructor for StorageService. - /// + /// /// Args: /// [storage]: Optional Firebase Storage instance (auto-created if null) /// [errorService]: Optional error service instance (auto-created if null) - StorageService({ - FirebaseStorage? storage, - ErrorService? errorService, - }) : _storage = storage ?? FirebaseStorage.instance, - _errorService = errorService ?? ErrorService(); + StorageService({FirebaseStorage? storage, ErrorService? errorService}) + : _storage = storage ?? FirebaseStorage.instance, + _errorService = errorService ?? ErrorService(); /// Uploads a receipt image for an expense with automatic compression. - /// + /// /// Validates the image file, compresses it to JPEG format with 85% quality, /// generates a unique filename, and uploads it with appropriate metadata. /// Monitors upload progress and logs it for debugging purposes. - /// + /// /// Args: /// [groupId]: ID of the group this receipt belongs to /// [imageFile]: The image file to upload - /// + /// /// Returns: - /// A Future containing the download URL of the uploaded image - /// + /// A `Future` containing the download URL of the uploaded image + /// /// Throws: /// Exception if file validation fails or upload encounters an error Future uploadReceiptImage(String groupId, File imageFile) async { @@ -95,8 +94,12 @@ class StorageService { // Progress monitoring (optional) uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) { - final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; - _errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%'); + final progress = + (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + _errorService.logInfo( + 'StorageService', + 'Upload progress: ${progress.toStringAsFixed(1)}%', + ); }); // Wait for completion @@ -105,9 +108,11 @@ class StorageService { // Get download URL final downloadUrl = await snapshot.ref.getDownloadURL(); - _errorService.logSuccess('StorageService', 'Image uploaded successfully: $fileName'); + _errorService.logSuccess( + 'StorageService', + 'Image uploaded successfully: $fileName', + ); return downloadUrl; - } catch (e) { _errorService.logError('StorageService', 'Error uploading image: $e'); rethrow; @@ -115,10 +120,10 @@ class StorageService { } /// Deletes a receipt image from storage. - /// + /// /// Extracts the storage reference from the provided URL and deletes the file. /// Does not throw errors to avoid blocking expense deletion operations. - /// + /// /// Args: /// [imageUrl]: The download URL of the image to delete Future deleteReceiptImage(String imageUrl) async { @@ -137,17 +142,17 @@ class StorageService { } /// Compresses an image to optimize storage space and upload speed. - /// + /// /// Reads the image file, decodes it, resizes it if too large (max 1024x1024), /// and encodes it as JPEG with 85% quality for optimal balance between /// file size and image quality. - /// + /// /// Args: /// [imageFile]: The image file to compress - /// + /// /// Returns: - /// A Future containing the compressed image bytes - /// + /// A `Future` containing the compressed image bytes + /// /// Throws: /// Exception if the image cannot be decoded or processed Future _compressImage(File imageFile) async { @@ -175,9 +180,11 @@ class StorageService { // Encode as JPEG with compression final compressedBytes = img.encodeJpg(image, quality: 85); - - _errorService.logInfo('StorageService', - 'Image compressed: ${bytes.length} → ${compressedBytes.length} bytes'); + + _errorService.logInfo( + 'StorageService', + 'Image compressed: ${bytes.length} → ${compressedBytes.length} bytes', + ); return Uint8List.fromList(compressedBytes); } catch (e) { @@ -188,13 +195,13 @@ class StorageService { } /// Validates an image file before upload. - /// + /// /// Checks file existence, size constraints (max 10MB), and file extension /// to ensure only valid image files are processed for upload. - /// + /// /// Args: /// [imageFile]: The image file to validate - /// + /// /// Throws: /// Exception if validation fails (file doesn't exist, too large, or invalid extension) void _validateImageFile(File imageFile) { @@ -219,13 +226,13 @@ class StorageService { } /// Generates a unique filename for a receipt image. - /// + /// /// Creates a filename using timestamp, microseconds, and group ID to ensure /// uniqueness and prevent naming conflicts when multiple receipts are uploaded. - /// + /// /// Args: /// [groupId]: ID of the group this receipt belongs to - /// + /// /// Returns: /// A unique filename string for the receipt image String _generateReceiptFileName(String groupId) { @@ -235,21 +242,23 @@ class StorageService { } /// Uploads multiple images simultaneously (for future features). - /// + /// /// Processes multiple image files in parallel for batch upload scenarios. /// Each image is validated, compressed, and uploaded with unique filenames. - /// + /// /// Args: /// [groupId]: ID of the group these images belong to /// [imageFiles]: List of image files to upload - /// + /// /// Returns: - /// A Future> containing download URLs of uploaded images + /// A `Future>` containing download URLs of uploaded images Future> uploadMultipleImages( - String groupId, + String groupId, List imageFiles, ) async { - final uploadTasks = imageFiles.map((file) => uploadReceiptImage(groupId, file)); + final uploadTasks = imageFiles.map( + (file) => uploadReceiptImage(groupId, file), + ); return await Future.wait(uploadTasks); } @@ -259,7 +268,10 @@ class StorageService { final ref = _storage.refFromURL(imageUrl); return await ref.getMetadata(); } catch (e) { - _errorService.logError('StorageService', 'Erreur récupération metadata: $e'); + _errorService.logError( + 'StorageService', + 'Erreur récupération metadata: $e', + ); return null; } } @@ -274,14 +286,17 @@ class StorageService { // 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}'); + _errorService.logInfo( + 'StorageService', + 'Image orpheline supprimée: ${ref.name}', + ); } } } @@ -295,17 +310,20 @@ class StorageService { 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'); + _errorService.logError( + 'StorageService', + 'Erreur calcul taille storage: $e', + ); return 0; } } -} \ No newline at end of file +} diff --git a/lib/services/trip_geocoding_service.dart b/lib/services/trip_geocoding_service.dart index 417d54e..685f0d8 100644 --- a/lib/services/trip_geocoding_service.dart +++ b/lib/services/trip_geocoding_service.dart @@ -3,10 +3,12 @@ import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/trip.dart'; import 'error_service.dart'; +import 'logger_service.dart'; /// Service pour géocoder les destinations des voyages class TripGeocodingService { - static final TripGeocodingService _instance = TripGeocodingService._internal(); + static final TripGeocodingService _instance = + TripGeocodingService._internal(); factory TripGeocodingService() => _instance; TripGeocodingService._internal(); @@ -16,35 +18,41 @@ class TripGeocodingService { /// Géocode la destination d'un voyage et retourne un Trip mis à jour Future geocodeTrip(Trip trip) async { try { - print('🌍 [TripGeocoding] Géocodage de "${trip.location}"'); + LoggerService.info('🌍 [TripGeocoding] Géocodage de "${trip.location}"'); // Vérifier si on a déjà des coordonnées récentes if (trip.hasRecentCoordinates) { - print('✅ [TripGeocoding] Coordonnées récentes trouvées, pas de géocodage nécessaire'); + LoggerService.info( + '✅ [TripGeocoding] Coordonnées récentes trouvées, pas de géocodage nécessaire', + ); return trip; } if (_apiKey.isEmpty) { - print('❌ [TripGeocoding] Clé API Google Maps manquante'); + LoggerService.error('❌ [TripGeocoding] Clé API Google Maps manquante'); throw Exception('Clé API Google Maps non configurée'); } final coordinates = await _geocodeDestination(trip.location); if (coordinates != null) { - print('✅ [TripGeocoding] Coordonnées trouvées: ${coordinates['lat']}, ${coordinates['lng']}'); - + LoggerService.info( + '✅ [TripGeocoding] Coordonnées trouvées: ${coordinates['lat']}, ${coordinates['lng']}', + ); + return trip.copyWith( latitude: coordinates['lat'], longitude: coordinates['lng'], lastGeocodingUpdate: DateTime.now(), ); } else { - print('⚠️ [TripGeocoding] Impossible de géocoder "${trip.location}"'); + LoggerService.warning( + '⚠️ [TripGeocoding] Impossible de géocoder "${trip.location}"', + ); return trip; } } catch (e) { - print('❌ [TripGeocoding] Erreur lors du géocodage: $e'); + LoggerService.error('❌ [TripGeocoding] Erreur lors du géocodage: $e'); _errorService.logError('trip_geocoding_service', e); return trip; // Retourner le voyage original en cas d'erreur } @@ -53,18 +61,23 @@ class TripGeocodingService { /// Géocode une destination et retourne les coordonnées Future?> _geocodeDestination(String destination) async { try { - final url = 'https://maps.googleapis.com/maps/api/geocode/json' + final url = + 'https://maps.googleapis.com/maps/api/geocode/json' '?address=${Uri.encodeComponent(destination)}' '&key=$_apiKey'; - print('🌐 [TripGeocoding] URL = $url'); + LoggerService.info('🌐 [TripGeocoding] URL = $url'); final response = await http.get(Uri.parse(url)); - print('📡 [TripGeocoding] Status code = ${response.statusCode}'); + LoggerService.info( + '📡 [TripGeocoding] Status code = ${response.statusCode}', + ); if (response.statusCode == 200) { final data = json.decode(response.body); - print('📋 [TripGeocoding] Réponse géocodage = ${data['status']}'); + LoggerService.info( + '📋 [TripGeocoding] Réponse géocodage = ${data['status']}', + ); if (data['status'] == 'OK' && data['results'].isNotEmpty) { final location = data['results'][0]['geometry']['location']; @@ -72,18 +85,24 @@ class TripGeocodingService { 'lat': (location['lat'] as num).toDouble(), 'lng': (location['lng'] as num).toDouble(), }; - print('📍 [TripGeocoding] Coordonnées trouvées = $coordinates'); + LoggerService.info( + '📍 [TripGeocoding] Coordonnées trouvées = $coordinates', + ); return coordinates; } else { - print('⚠️ [TripGeocoding] Erreur API = ${data['error_message'] ?? data['status']}'); + LoggerService.warning( + '⚠️ [TripGeocoding] Erreur API = ${data['error_message'] ?? data['status']}', + ); return null; } } else { - print('❌ [TripGeocoding] Erreur HTTP ${response.statusCode}'); + LoggerService.error( + '❌ [TripGeocoding] Erreur HTTP ${response.statusCode}', + ); return null; } } catch (e) { - print('❌ [TripGeocoding] Exception lors du géocodage: $e'); + LoggerService.error('❌ [TripGeocoding] Exception lors du géocodage: $e'); _errorService.logError('trip_geocoding_service', e); return null; } @@ -96,23 +115,25 @@ class TripGeocodingService { /// Géocode plusieurs voyages en batch Future> geocodeTrips(List trips) async { - print('🔄 [TripGeocoding] Géocodage de ${trips.length} voyages'); - + LoggerService.info( + '🔄 [TripGeocoding] Géocodage de ${trips.length} voyages', + ); + final List geocodedTrips = []; - + for (final trip in trips) { if (needsGeocoding(trip)) { final geocodedTrip = await geocodeTrip(trip); geocodedTrips.add(geocodedTrip); - + // Petit délai pour éviter de saturer l'API Google await Future.delayed(const Duration(milliseconds: 200)); } else { geocodedTrips.add(trip); } } - - print('✅ [TripGeocoding] Géocodage terminé'); + + LoggerService.info('✅ [TripGeocoding] Géocodage terminé'); return geocodedTrips; } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 01a2430..5b3eba3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -617,7 +617,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" @@ -825,7 +825,7 @@ packages: source: hosted version: "3.2.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index 4dc65af..5988676 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: sign_in_with_apple: ^7.0.1 sign_in_button: ^4.0.1 cached_network_image: ^3.3.1 + path: ^1.9.1 + image: ^4.5.4 dev_dependencies: flutter_launcher_icons: ^0.13.1 diff --git a/scripts/cleanup_images.dart b/scripts/cleanup_images.dart index cbd8179..648fac6 100644 --- a/scripts/cleanup_images.dart +++ b/scripts/cleanup_images.dart @@ -20,7 +20,7 @@ void main() async { if (stats['tripsWithImages'] > 0) { await tripImageService.cleanupUnusedImages(userId); - final newStats = await tripImageService.getImageStatistics(userId); + await tripImageService.getImageStatistics(userId); } else {} } catch (e) { exit(1); diff --git a/scripts/diagnose_images.dart b/scripts/diagnose_images.dart index 5e9c73a..4e5d32e 100644 --- a/scripts/diagnose_images.dart +++ b/scripts/diagnose_images.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:travel_mate/firebase_options.dart'; @@ -32,7 +33,7 @@ void main() async { final location = customMeta['location'] ?? 'Inconnue'; final normalizedLocation = customMeta['normalizedLocation'] ?? 'Non définie'; - final source = customMeta['source'] ?? 'Inconnue'; + final uploadedAt = customMeta['uploadedAt'] ?? 'Inconnue'; // Récupérer l'URL de téléchargement @@ -76,18 +77,17 @@ void main() async { int totalDuplicates = 0; for (final entry in locationGroups.entries) { - final location = entry.key; final images = entry.value; if (images.length > 1) { totalDuplicates += images.length - 1; - for (int i = 0; i < images.length; i++) { - final image = images[i]; - } + for (int i = 0; i < images.length; i++) {} } else {} } if (totalDuplicates > 0) {} - } catch (e) {} + } catch (e) { + print('Erreur globale: $e'); + } } diff --git a/test/image_loading_optimization_test.dart b/test/image_loading_optimization_test.dart index 0c25cc8..05aa8c1 100644 --- a/test/image_loading_optimization_test.dart +++ b/test/image_loading_optimization_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:flutter_test/flutter_test.dart'; void main() { @@ -13,7 +14,7 @@ void main() { print('✓ Téléchargement d\'une nouvelle image depuis Google Places'); existingImage = 'https://storage.googleapis.com/image1.jpg'; print('✓ Image sauvée: $existingImage'); - + expect(existingImage, isNotNull); // Scénario 2: Rechargement (image existante) @@ -34,7 +35,7 @@ void main() { print('✓ Nouvelle destination, aucune image existante'); print('✓ Téléchargement autorisé pour cette nouvelle destination'); differentLocationImage = 'https://storage.googleapis.com/image2.jpg'; - + expect(differentLocationImage, isNotNull); expect(differentLocationImage, isNot(equals(existingImage))); diff --git a/test/photo_quality_test.dart b/test/photo_quality_test.dart index 84faa4f..134361b 100644 --- a/test/photo_quality_test.dart +++ b/test/photo_quality_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: avoid_print import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/place_image_service_test.dart b/test/place_image_service_test.dart index 4d0f281..c23fd22 100644 --- a/test/place_image_service_test.dart +++ b/test/place_image_service_test.dart @@ -1,10 +1,11 @@ +// ignore_for_file: avoid_print import 'package:flutter_test/flutter_test.dart'; import 'package:travel_mate/services/place_image_service.dart'; void main() { group('PlaceImageService Tests', () { late PlaceImageService placeImageService; - + setUp(() { placeImageService = PlaceImageService(); }); @@ -12,9 +13,9 @@ void main() { test('should generate search terms correctly for Paris', () { // Cette fonction n'est pas publique, mais nous pouvons tester indirectement // en vérifiant que différentes villes génèrent des termes appropriés - + final cities = ['Paris', 'London', 'Rome', 'New York', 'Tokyo']; - + for (String city in cities) { print('Testing search terms generation for: $city'); // Le test indirect sera fait lors de l'appel réel à l'API @@ -23,14 +24,8 @@ void main() { }); test('should prioritize tourist attractions in search terms', () { - const testCases = [ - 'Paris', - 'London', - 'Rome', - 'New York', - 'Tokyo' - ]; - + const testCases = ['Paris', 'London', 'Rome', 'New York', 'Tokyo']; + for (String city in testCases) { print('City: $city should have tourist attraction terms'); expect(city.length, greaterThan(0)); @@ -41,7 +36,7 @@ void main() { test('should handle API key missing gracefully', () async { // Test avec une clé API vide final result = await placeImageService.getPlaceImageUrl('Paris'); - + // Devrait retourner null si pas de clé API expect(result, isNull); }); @@ -57,12 +52,12 @@ void main() { 'Louvre Paris', 'Champs-Élysées Paris', ]; - + for (String term in parisTerms) { expect(term.contains('Paris'), true); print('Generated term: $term'); } - + // Test des termes spécifiques pour Londres const londonTerms = [ 'Big Ben London', @@ -71,7 +66,7 @@ void main() { 'Buckingham Palace London', 'Tower of London', ]; - + for (String term in londonTerms) { expect(term.contains('London') || term.contains('Eye'), true); print('Generated term: $term'); @@ -88,7 +83,7 @@ void main() { 'centre ville', 'skyline', ]; - + for (String term in genericTerms) { expect(term.isNotEmpty, true); print('Generic term: $term'); @@ -105,32 +100,34 @@ void main() { {'width': 300, 'height': 300}, // Carré {'width': 1200, 'height': 800}, // Horizontal haute résolution ]; - + // Le tri devrait favoriser les photos horizontales photos.sort((a, b) { final aRatio = a['width']! / a['height']!; final bRatio = b['width']! / b['height']!; - + // Favoriser les ratios > 1 (horizontal) if (aRatio > 1 && bRatio <= 1) return -1; if (bRatio > 1 && aRatio <= 1) return 1; - + // Si les deux sont horizontaux, favoriser la plus haute résolution final aResolution = a['width']! * a['height']!; final bResolution = b['width']! * b['height']!; - + return bResolution.compareTo(aResolution); }); - + // La première photo devrait être celle avec la plus haute résolution horizontale expect(photos.first['width'], 1200); expect(photos.first['height'], 800); - + print('Photos triées par qualité:'); for (var photo in photos) { final ratio = photo['width']! / photo['height']!; - print('${photo['width']}x${photo['height']} (ratio: ${ratio.toStringAsFixed(2)})'); + print( + '${photo['width']}x${photo['height']} (ratio: ${ratio.toStringAsFixed(2)})', + ); } }); }); -} \ No newline at end of file +}