Enhance model and service documentation with detailed comments and descriptions

- Updated Group, Trip, User, and other model classes to include comprehensive documentation for better understanding and maintainability.
- Improved error handling and logging in services, including AuthService, ErrorService, and StorageService.
- Added validation and business logic explanations in ExpenseService and TripService.
- Refactored method comments to follow a consistent format across the codebase.
- Translated error messages and comments from French to English for consistency.
This commit is contained in:
Dayron
2025-10-30 15:56:17 +01:00
parent 1eeea6997e
commit 2faf37f145
46 changed files with 2656 additions and 220 deletions

View File

@@ -1,3 +1,26 @@
/// A BLoC (Business Logic Component) that manages account-related state and operations.
///
/// This bloc handles account operations such as loading accounts by user ID,
/// creating new accounts, and managing real-time updates from the account repository.
/// It uses stream subscriptions to listen for account changes and emits corresponding states.
///
/// The bloc supports the following operations:
/// - Loading accounts by user ID with real-time updates
/// - Creating a single account without members
/// - Creating an account with associated members
///
/// All errors are logged using [ErrorService] and emitted as [AccountError] states.
///
/// Example usage:
/// ```dart
/// final accountBloc = AccountBloc(accountRepository);
/// accountBloc.add(LoadAccountsByUserId('user123'));
/// ```
///
/// Remember to close the bloc when done to cancel active subscriptions:
/// ```dart
/// accountBloc.close();
/// ```
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';

View File

@@ -1,7 +1,12 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/account.dart'; import 'package:travel_mate/models/account.dart';
import '../../models/group_member.dart'; import 'package:travel_mate/models/group_member.dart';
/// Abstract base class for account-related events in the BLoC pattern.
///
/// This class extends [Equatable] to enable value comparison for events.
/// All account events should inherit from this class and implement the [props] getter.
abstract class AccountEvent extends Equatable { abstract class AccountEvent extends Equatable {
const AccountEvent(); const AccountEvent();
@@ -9,6 +14,10 @@ abstract class AccountEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to load accounts associated with a specific user ID.
///
/// This event is dispatched when the application needs to fetch accounts
/// for a given user, typically for displaying user-specific account data.
class LoadAccountsByUserId extends AccountEvent { class LoadAccountsByUserId extends AccountEvent {
final String userId; final String userId;
@@ -18,6 +27,10 @@ class LoadAccountsByUserId extends AccountEvent {
List<Object?> get props => [userId]; List<Object?> get props => [userId];
} }
/// Event to load accounts associated with a specific trip.
///
/// This event is dispatched when the application needs to fetch accounts
/// related to a particular trip, such as for trip expense management.
class LoadAccountsByTrip extends AccountEvent { class LoadAccountsByTrip extends AccountEvent {
final String tripId; final String tripId;
@@ -27,6 +40,10 @@ class LoadAccountsByTrip extends AccountEvent {
List<Object?> get props => [tripId]; List<Object?> get props => [tripId];
} }
/// Event to create a new account.
///
/// This event is dispatched when a new account needs to be created,
/// passing the [Account] object containing the account details.
class CreateAccount extends AccountEvent { class CreateAccount extends AccountEvent {
final Account account; final Account account;
@@ -36,6 +53,10 @@ class CreateAccount extends AccountEvent {
List<Object?> get props => [account]; List<Object?> get props => [account];
} }
/// Event to update an existing account.
///
/// This event is dispatched when an account needs to be modified,
/// requiring the [accountId] to identify the account and the updated [Account] object.
class UpdateAccount extends AccountEvent { class UpdateAccount extends AccountEvent {
final String accountId; final String accountId;
final Account account; final Account account;
@@ -49,6 +70,10 @@ class UpdateAccount extends AccountEvent {
List<Object?> get props => [accountId, account]; List<Object?> get props => [accountId, account];
} }
/// Event to create a new account along with its group members.
///
/// This event is dispatched when creating an account that includes
/// a list of [GroupMember] objects, such as for shared trip accounts.
class CreateAccountWithMembers extends AccountEvent { class CreateAccountWithMembers extends AccountEvent {
final Account account; final Account account;
final List<GroupMember> members; final List<GroupMember> members;

View File

@@ -1,3 +1,38 @@
/// The abstract base class for all account-related states used by the AccountBloc.
///
/// Extends Equatable to enable value-based comparisons between state
/// instances. Subclasses should provide the relevant properties by
/// overriding `props` so that the bloc can correctly determine whether
/// the state has changed.
/// Represents the initial state of the account feature.
///
/// Used before any account-related action has started or when the bloc
/// has been freshly created.
/// Indicates that an account-related operation is currently in progress.
///
/// This state is typically emitted while fetching account data, creating,
/// updating, or deleting an account so the UI can show a loading indicator.
/// Emitted when a collection of accounts has been successfully loaded.
///
/// Contains:
/// - `accounts`: the list of Account models retrieved from the repository.
///
/// Use this state to display fetched account data in the UI.
/// Represents a successful account operation that does not necessarily
/// carry account data (e.g., after creating, updating, or deleting an account).
///
/// Contains:
/// - `message`: a human-readable success message that can be shown to the user.
/// Represents an error that occurred during an account-related operation.
///
/// Contains:
/// - `message`: a human-readable error description suitable for logging
/// or displaying to the user.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/account.dart'; import '../../models/account.dart';

View File

@@ -1,11 +1,38 @@
/// Business Logic Component for managing authentication state.
///
/// The [AuthBloc] handles authentication-related events and manages the
/// authentication state throughout the application. It coordinates with
/// the [AuthRepository] to perform authentication operations and emits
/// appropriate states based on the results.
///
/// Supported authentication methods:
/// - Email and password authentication
/// - Google Sign-In
/// - Apple Sign-In
/// - Password reset functionality
///
/// This bloc handles the following events:
/// - [AuthCheckRequested]: Verifies current authentication status
/// - [AuthSignInRequested]: Processes email/password sign-in
/// - [AuthSignUpRequested]: Processes user registration
/// - [AuthGoogleSignInRequested]: Processes Google authentication
/// - [AuthAppleSignInRequested]: Processes Apple authentication
/// - [AuthSignOutRequested]: Processes user sign-out
/// - [AuthPasswordResetRequested]: Processes password reset requests
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repositories/auth_repository.dart'; import '../../repositories/auth_repository.dart';
import 'auth_event.dart'; import 'auth_event.dart';
import 'auth_state.dart'; import 'auth_state.dart';
/// BLoC for managing authentication state and operations.
class AuthBloc extends Bloc<AuthEvent, AuthState> { class AuthBloc extends Bloc<AuthEvent, AuthState> {
/// Repository for authentication operations.
final AuthRepository _authRepository; final AuthRepository _authRepository;
/// Creates an [AuthBloc] with the provided [authRepository].
///
/// The bloc starts in the [AuthInitial] state and registers event handlers
/// for all supported authentication events.
AuthBloc({required AuthRepository authRepository}) AuthBloc({required AuthRepository authRepository})
: _authRepository = authRepository, : _authRepository = authRepository,
super(AuthInitial()) { super(AuthInitial()) {
@@ -18,6 +45,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<AuthPasswordResetRequested>(_onPasswordResetRequested); on<AuthPasswordResetRequested>(_onPasswordResetRequested);
} }
/// Handles [AuthCheckRequested] events.
///
/// Checks if a user is currently authenticated and emits the appropriate state.
/// If a user is found, attempts to fetch user data from Firestore.
Future<void> _onAuthCheckRequested( Future<void> _onAuthCheckRequested(
AuthCheckRequested event, AuthCheckRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -28,7 +59,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final currentUser = _authRepository.currentUser; final currentUser = _authRepository.currentUser;
if (currentUser != null) { if (currentUser != null) {
// Récupérer les données utilisateur depuis Firestore // Fetch user data from Firestore
final user = await _authRepository.getUserFromFirestore(currentUser.uid); final user = await _authRepository.getUserFromFirestore(currentUser.uid);
if (user != null) { if (user != null) {
@@ -44,6 +75,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
} }
} }
/// Handles [AuthSignInRequested] events.
///
/// Attempts to sign in a user with the provided email and password.
/// Emits [AuthAuthenticated] on success or [AuthError] on failure.
Future<void> _onSignInRequested( Future<void> _onSignInRequested(
AuthSignInRequested event, AuthSignInRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -59,13 +94,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) { if (user != null) {
emit(AuthAuthenticated(user: user)); emit(AuthAuthenticated(user: user));
} else { } else {
emit(const AuthError(message: 'Email ou mot de passe incorrect')); emit(const AuthError(message: 'Invalid email or password'));
} }
} catch (e) { } catch (e) {
emit(AuthError(message: e.toString())); emit(AuthError(message: e.toString()));
} }
} }
/// Handles [AuthSignUpRequested] events.
///
/// Attempts to create a new user account with the provided information.
/// Emits [AuthAuthenticated] on success or [AuthError] on failure.
Future<void> _onSignUpRequested( Future<void> _onSignUpRequested(
AuthSignUpRequested event, AuthSignUpRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -83,13 +122,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) { if (user != null) {
emit(AuthAuthenticated(user: user)); emit(AuthAuthenticated(user: user));
} else { } else {
emit(const AuthError(message: 'Erreur lors de l\'inscription')); emit(const AuthError(message: 'Registration failed'));
} }
} catch (e) { } catch (e) {
emit(AuthError(message: e.toString())); emit(AuthError(message: e.toString()));
} }
} }
/// Handles [AuthGoogleSignInRequested] events.
///
/// Attempts to sign in the user using Google authentication.
/// Emits [AuthAuthenticated] on success or [AuthError] on failure.
Future<void> _onGoogleSignInRequested( Future<void> _onGoogleSignInRequested(
AuthGoogleSignInRequested event, AuthGoogleSignInRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -102,13 +145,17 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) { if (user != null) {
emit(AuthAuthenticated(user: user)); emit(AuthAuthenticated(user: user));
} else { } else {
emit(const AuthError(message: 'Connexion Google annulée')); emit(const AuthError(message: 'Google sign-in cancelled'));
} }
} catch (e) { } catch (e) {
emit(AuthError(message: e.toString())); emit(AuthError(message: e.toString()));
} }
} }
/// Handles [AuthAppleSignInRequested] events.
///
/// Attempts to sign in the user using Apple authentication.
/// Emits [AuthAuthenticated] on success or [AuthError] on failure.
Future<void> _onAppleSignInRequested( Future<void> _onAppleSignInRequested(
AuthAppleSignInRequested event, AuthAppleSignInRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -121,13 +168,16 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) { if (user != null) {
emit(AuthAuthenticated(user: user)); emit(AuthAuthenticated(user: user));
} else { } else {
emit(const AuthError(message: 'Connexion Apple annulée')); emit(const AuthError(message: 'Apple sign-in cancelled'));
} }
} catch (e) { } catch (e) {
emit(AuthError(message: e.toString())); emit(AuthError(message: e.toString()));
} }
} }
/// Handles [AuthSignOutRequested] events.
///
/// Signs out the current user and emits [AuthUnauthenticated].
Future<void> _onSignOutRequested( Future<void> _onSignOutRequested(
AuthSignOutRequested event, AuthSignOutRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
@@ -136,6 +186,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(AuthUnauthenticated()); emit(AuthUnauthenticated());
} }
/// Handles [AuthPasswordResetRequested] events.
///
/// Sends a password reset email to the specified email address.
/// Emits [AuthPasswordResetSent] on success or [AuthError] on failure.
Future<void> _onPasswordResetRequested( Future<void> _onPasswordResetRequested(
AuthPasswordResetRequested event, AuthPasswordResetRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,

View File

@@ -1,18 +1,37 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Abstract base class for all authentication-related events.
///
/// This class extends [Equatable] to enable value equality for event comparison.
/// All authentication events in the application should inherit from this class.
abstract class AuthEvent extends Equatable { abstract class AuthEvent extends Equatable {
/// Creates a new [AuthEvent].
const AuthEvent(); const AuthEvent();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to check the current authentication status.
///
/// This event is typically dispatched when the app starts to determine
/// if a user is already authenticated.
class AuthCheckRequested extends AuthEvent {} class AuthCheckRequested extends AuthEvent {}
/// Event to request user sign-in with email and password.
///
/// This event contains the user's credentials and triggers the authentication
/// process when dispatched to the [AuthBloc].
class AuthSignInRequested extends AuthEvent { class AuthSignInRequested extends AuthEvent {
/// The user's email address.
final String email; final String email;
/// The user's password.
final String password; final String password;
/// Creates a new [AuthSignInRequested] event.
///
/// Both [email] and [password] are required parameters.
const AuthSignInRequested({ const AuthSignInRequested({
required this.email, required this.email,
required this.password, required this.password,
@@ -22,12 +41,26 @@ class AuthSignInRequested extends AuthEvent {
List<Object?> get props => [email, password]; List<Object?> get props => [email, password];
} }
/// Event to request user registration with email, password, and personal information.
///
/// This event contains all necessary information to create a new user account
/// and triggers the registration process when dispatched to the [AuthBloc].
class AuthSignUpRequested extends AuthEvent { class AuthSignUpRequested extends AuthEvent {
/// The user's email address.
final String email; final String email;
/// The user's password.
final String password; final String password;
/// The user's last name.
final String nom; final String nom;
/// The user's first name.
final String prenom; final String prenom;
/// Creates a new [AuthSignUpRequested] event.
///
/// All parameters are required for user registration.
const AuthSignUpRequested({ const AuthSignUpRequested({
required this.email, required this.email,
required this.password, required this.password,
@@ -39,15 +72,33 @@ class AuthSignUpRequested extends AuthEvent {
List<Object?> get props => [email, password, nom, prenom]; List<Object?> get props => [email, password, nom, prenom];
} }
/// Event to request user sign-in using Google authentication.
///
/// This event triggers the Google sign-in flow when dispatched to the [AuthBloc].
class AuthGoogleSignInRequested extends AuthEvent {} class AuthGoogleSignInRequested extends AuthEvent {}
/// Event to request user sign-in using Apple authentication.
///
/// This event triggers the Apple sign-in flow when dispatched to the [AuthBloc].
class AuthAppleSignInRequested extends AuthEvent {} class AuthAppleSignInRequested extends AuthEvent {}
/// Event to request user sign-out.
///
/// This event triggers the sign-out process and clears the user session
/// when dispatched to the [AuthBloc].
class AuthSignOutRequested extends AuthEvent {} class AuthSignOutRequested extends AuthEvent {}
/// Event to request a password reset for a user account.
///
/// This event triggers the password reset process by sending a reset email
/// to the specified email address.
class AuthPasswordResetRequested extends AuthEvent { class AuthPasswordResetRequested extends AuthEvent {
/// The email address to send the password reset link to.
final String email; final String email;
/// Creates a new [AuthPasswordResetRequested] event.
///
/// The [email] parameter is required.
const AuthPasswordResetRequested({required this.email}); const AuthPasswordResetRequested({required this.email});
@override @override

View File

@@ -1,40 +1,75 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/user.dart'; import '../../models/user.dart';
/// Abstract base class for all authentication states.
///
/// This class extends [Equatable] to enable value equality for state comparison.
/// All authentication states in the application should inherit from this class.
abstract class AuthState extends Equatable { abstract class AuthState extends Equatable {
/// Creates a new [AuthState].
const AuthState(); const AuthState();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Initial state of the authentication bloc.
///
/// This state represents the initial state before any authentication
/// actions have been performed.
class AuthInitial extends AuthState {} class AuthInitial extends AuthState {}
/// State indicating that an authentication operation is in progress.
///
/// This state is used to show loading indicators during authentication
/// processes like sign-in, sign-up, or sign-out.
class AuthLoading extends AuthState {} class AuthLoading extends AuthState {}
/// State indicating that a user is successfully authenticated.
///
/// This state contains the authenticated user's information and is
/// used throughout the app to access user data.
class AuthAuthenticated extends AuthState { class AuthAuthenticated extends AuthState {
/// The authenticated user.
final User user; final User user;
/// Creates an [AuthAuthenticated] state with the given [user].
const AuthAuthenticated({required this.user}); const AuthAuthenticated({required this.user});
@override @override
List<Object?> get props => [user]; List<Object?> get props => [user];
} }
/// State indicating that no user is currently authenticated.
///
/// This state is used when a user is not signed in or has signed out
/// of the application.
class AuthUnauthenticated extends AuthState {} class AuthUnauthenticated extends AuthState {}
/// State indicating that an authentication error has occurred.
///
/// This state contains an error message that can be displayed to the user
/// when authentication operations fail.
class AuthError extends AuthState { class AuthError extends AuthState {
/// The error message describing what went wrong.
final String message; final String message;
/// Creates an [AuthError] state with the given error [message].
const AuthError({required this.message}); const AuthError({required this.message});
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
/// State indicating that a password reset email has been sent.
///
/// This state is used to confirm to the user that a password reset
/// email has been successfully sent to their email address.
class AuthPasswordResetSent extends AuthState { class AuthPasswordResetSent extends AuthState {
/// The email address to which the reset link was sent.
final String email; final String email;
/// Creates an [AuthPasswordResetSent] state with the given [email].
const AuthPasswordResetSent({required this.email}); const AuthPasswordResetSent({required this.email});
@override @override

View File

@@ -44,6 +44,16 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
final BalanceService _balanceService; final BalanceService _balanceService;
final ErrorService _errorService; 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
/// [balanceService]: Optional service for balance calculations (auto-created if null)
/// [errorService]: Optional service for error handling (auto-created if null)
BalanceBloc({ BalanceBloc({
required BalanceRepository balanceRepository, required BalanceRepository balanceRepository,
required ExpenseRepository expenseRepository, required ExpenseRepository expenseRepository,
@@ -58,6 +68,15 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
on<MarkSettlementAsCompleted>(_onMarkSettlementAsCompleted); on<MarkSettlementAsCompleted>(_onMarkSettlementAsCompleted);
} }
/// 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
Future<void> _onLoadGroupBalance( Future<void> _onLoadGroupBalance(
LoadGroupBalances event, LoadGroupBalances event,
Emitter<BalanceState> emit, Emitter<BalanceState> emit,
@@ -65,10 +84,10 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
try { try {
emit(BalanceLoading()); emit(BalanceLoading());
// Calculer les balances du groupe // Calculate group user balances
final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId); final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId);
// Calculer les règlements optimisés // Calculate optimal settlements
final settlements = await _balanceService.calculateOptimalSettlements(event.groupId); final settlements = await _balanceService.calculateOptimalSettlements(event.groupId);
emit(GroupBalancesLoaded( emit(GroupBalancesLoaded(
@@ -76,25 +95,34 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
settlements: settlements, settlements: settlements,
)); ));
} catch (e) { } catch (e) {
_errorService.logError('BalanceBloc', 'Erreur chargement balance: $e'); _errorService.logError('BalanceBloc', 'Error loading balance: $e');
emit(BalanceError(e.toString())); emit(BalanceError(e.toString()));
} }
} }
/// 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
Future<void> _onRefreshBalance( Future<void> _onRefreshBalance(
RefreshBalance event, RefreshBalance event,
Emitter<BalanceState> emit, Emitter<BalanceState> emit,
) async { ) async {
try { try {
// Garde l'état actuel pendant le refresh si possible // Keep current state during refresh if possible
if (state is! GroupBalancesLoaded) { if (state is! GroupBalancesLoaded) {
emit(BalanceLoading()); emit(BalanceLoading());
} }
// Calculer les balances du groupe // Calculate group user balances
final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId); final userBalances = await _balanceRepository.calculateGroupUserBalances(event.groupId);
// Calculer les règlements optimisés // Calculate optimal settlements
final settlements = await _balanceService.calculateOptimalSettlements(event.groupId); final settlements = await _balanceService.calculateOptimalSettlements(event.groupId);
emit(GroupBalancesLoaded( emit(GroupBalancesLoaded(
@@ -102,11 +130,20 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
settlements: settlements, settlements: settlements,
)); ));
} catch (e) { } catch (e) {
_errorService.logError('BalanceBloc', 'Erreur refresh balance: $e'); _errorService.logError('BalanceBloc', 'Error refreshing balance: $e');
emit(BalanceError(e.toString())); emit(BalanceError(e.toString()));
} }
} }
/// 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
Future<void> _onMarkSettlementAsCompleted( Future<void> _onMarkSettlementAsCompleted(
MarkSettlementAsCompleted event, MarkSettlementAsCompleted event,
Emitter<BalanceState> emit, Emitter<BalanceState> emit,
@@ -119,12 +156,12 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
amount: event.amount, amount: event.amount,
); );
emit(const BalanceOperationSuccess('Règlement marqué comme effectué')); emit(const BalanceOperationSuccess('Settlement marked as completed'));
// Recharger la balance après le règlement // Reload balance after settlement
add(RefreshBalance(event.groupId)); add(RefreshBalance(event.groupId));
} catch (e) { } catch (e) {
_errorService.logError('BalanceBloc', 'Erreur mark settlement: $e'); _errorService.logError('BalanceBloc', 'Error marking settlement: $e');
emit(BalanceError(e.toString())); emit(BalanceError(e.toString()));
} }
} }

View File

@@ -1,36 +1,68 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Abstract base class for all balance-related events.
///
/// This class extends [Equatable] to enable value equality for event comparison.
/// All balance events in the application should inherit from this class.
abstract class BalanceEvent extends Equatable { abstract class BalanceEvent extends Equatable {
/// Creates a new [BalanceEvent].
const BalanceEvent(); const BalanceEvent();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to load balance information for a specific group.
///
/// This event triggers the loading of user balances and settlements
/// for all members of the specified group. It calculates who owes
/// money to whom based on shared expenses.
class LoadGroupBalances extends BalanceEvent { class LoadGroupBalances extends BalanceEvent {
/// The ID of the group to load balances for.
final String groupId; final String groupId;
/// Creates a [LoadGroupBalances] event for the specified [groupId].
const LoadGroupBalances(this.groupId); const LoadGroupBalances(this.groupId);
@override @override
List<Object> get props => [groupId]; List<Object> get props => [groupId];
} }
/// Event to refresh balance calculations for a group.
///
/// This event recalculates all balances and settlements for a group,
/// typically called after expenses are added, modified, or deleted.
class RefreshBalance extends BalanceEvent { class RefreshBalance extends BalanceEvent {
/// The ID of the group to refresh balances for.
final String groupId; final String groupId;
/// Creates a [RefreshBalance] event for the specified [groupId].
const RefreshBalance(this.groupId); const RefreshBalance(this.groupId);
@override @override
List<Object?> get props => [groupId]; List<Object?> get props => [groupId];
} }
/// Event to mark a settlement as completed between two users.
///
/// This event is dispatched when one user pays back money they owe
/// to another user, updating the balance calculations accordingly.
class MarkSettlementAsCompleted extends BalanceEvent { class MarkSettlementAsCompleted extends BalanceEvent {
/// The ID of the group where the settlement occurred.
final String groupId; final String groupId;
/// The ID of the user who is paying the debt.
final String fromUserId; final String fromUserId;
/// The ID of the user who is receiving the payment.
final String toUserId; final String toUserId;
/// The amount being settled.
final double amount; final double amount;
/// Creates a [MarkSettlementAsCompleted] event with the settlement details.
///
/// All parameters are required to properly record the settlement.
const MarkSettlementAsCompleted({ const MarkSettlementAsCompleted({
required this.groupId, required this.groupId,
required this.fromUserId, required this.fromUserId,

View File

@@ -2,21 +2,48 @@ import 'package:equatable/equatable.dart';
import '../../models/settlement.dart'; import '../../models/settlement.dart';
import '../../models/user_balance.dart'; import '../../models/user_balance.dart';
/// Abstract base class for all balance-related states.
///
/// This class extends [Equatable] to enable value equality for state comparison.
/// All balance states in the application should inherit from this class.
abstract class BalanceState extends Equatable { abstract class BalanceState extends Equatable {
/// Creates a new [BalanceState].
const BalanceState(); const BalanceState();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Initial state of the balance bloc.
///
/// This state represents the initial state before any balance
/// operations have been performed.
class BalanceInitial extends BalanceState {} class BalanceInitial extends BalanceState {}
/// State indicating that a balance operation is in progress.
///
/// This state is used to show loading indicators during balance
/// calculations, data loading, or settlement operations.
class BalanceLoading extends BalanceState {} class BalanceLoading extends BalanceState {}
/// State indicating that group balances have been successfully loaded.
///
/// This state contains the calculated balances for all group members
/// and the list of settlements needed to balance all debts.
class GroupBalancesLoaded extends BalanceState { class GroupBalancesLoaded extends BalanceState {
/// List of user balances showing how much each user owes or is owed.
///
/// Positive balances indicate the user is owed money,
/// negative balances indicate the user owes money.
final List<UserBalance> balances; final List<UserBalance> balances;
/// List of settlements required to balance all debts in the group.
///
/// Each settlement represents a payment that needs to be made
/// from one user to another to settle shared expenses.
final List<Settlement> settlements; final List<Settlement> settlements;
/// Creates a [GroupBalancesLoaded] state with the calculated data.
const GroupBalancesLoaded({ const GroupBalancesLoaded({
required this.balances, required this.balances,
required this.settlements, required this.settlements,
@@ -26,18 +53,30 @@ class GroupBalancesLoaded extends BalanceState {
List<Object> get props => [balances, settlements]; List<Object> get props => [balances, settlements];
} }
/// State indicating that a balance operation has completed successfully.
///
/// This state is used to show success messages after operations like
/// marking settlements as completed or refreshing balance calculations.
class BalanceOperationSuccess extends BalanceState { class BalanceOperationSuccess extends BalanceState {
/// Success message to display to the user.
final String message; final String message;
/// Creates a [BalanceOperationSuccess] state with the given [message].
const BalanceOperationSuccess(this.message); const BalanceOperationSuccess(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
/// State indicating that a balance operation has failed.
///
/// This state contains an error message that can be displayed to the user
/// when balance operations fail.
class BalanceError extends BalanceState { class BalanceError extends BalanceState {
/// The error message describing what went wrong.
final String message; final String message;
/// Creates a [BalanceError] state with the given error [message].
const BalanceError(this.message); const BalanceError(this.message);
@override @override

View File

@@ -6,13 +6,29 @@ import '../../services/error_service.dart';
import 'expense_event.dart'; import 'expense_event.dart';
import 'expense_state.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
/// service to provide business logic and data persistence.
class ExpenseBloc extends Bloc<ExpenseEvent, ExpenseState> { class ExpenseBloc extends Bloc<ExpenseEvent, ExpenseState> {
/// Repository for expense data operations.
final ExpenseRepository _expenseRepository; final ExpenseRepository _expenseRepository;
/// Service for expense business logic and validation.
final ExpenseService _expenseService; final ExpenseService _expenseService;
/// Service for error handling and logging.
final ErrorService _errorService; final ErrorService _errorService;
/// Subscription to the expenses stream for real-time updates.
StreamSubscription? _expensesSubscription; 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({ ExpenseBloc({
required ExpenseRepository expenseRepository, required ExpenseRepository expenseRepository,
ExpenseService? expenseService, ExpenseService? expenseService,
@@ -31,6 +47,10 @@ class ExpenseBloc extends Bloc<ExpenseEvent, ExpenseState> {
on<ArchiveExpense>(_onArchiveExpense); on<ArchiveExpense>(_onArchiveExpense);
} }
/// 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<void> _onLoadExpensesByGroup( Future<void> _onLoadExpensesByGroup(
LoadExpensesByGroup event, LoadExpensesByGroup event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
@@ -47,11 +67,19 @@ class ExpenseBloc extends Bloc<ExpenseEvent, ExpenseState> {
onError: (error) => add(ExpensesUpdated([], error: error.toString())), onError: (error) => add(ExpensesUpdated([], error: error.toString())),
); );
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur chargement expenses: $e'); _errorService.logError('ExpenseBloc', 'Error loading expenses: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// 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
Future<void> _onExpensesUpdated( Future<void> _onExpensesUpdated(
ExpensesUpdated event, ExpensesUpdated event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
@@ -63,71 +91,119 @@ class ExpenseBloc extends Bloc<ExpenseEvent, ExpenseState> {
} }
} }
/// 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
Future<void> _onCreateExpense( Future<void> _onCreateExpense(
CreateExpense event, CreateExpense event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
) async { ) async {
try { try {
await _expenseService.createExpenseWithValidation(event.expense, event.receiptImage); await _expenseService.createExpenseWithValidation(event.expense, event.receiptImage);
emit(const ExpenseOperationSuccess('pense créée avec succès')); emit(const ExpenseOperationSuccess('Expense created successfully'));
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur création expense: $e'); _errorService.logError('ExpenseBloc', 'Error creating expense: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// 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
Future<void> _onUpdateExpense( Future<void> _onUpdateExpense(
UpdateExpense event, UpdateExpense event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
) async { ) async {
try { try {
await _expenseService.updateExpenseWithValidation(event.expense, event.newReceiptImage); await _expenseService.updateExpenseWithValidation(event.expense, event.newReceiptImage);
emit(const ExpenseOperationSuccess('pense modifiée avec succès')); emit(const ExpenseOperationSuccess('Expense updated successfully'));
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur mise à jour expense: $e'); _errorService.logError('ExpenseBloc', 'Error updating expense: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// 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
Future<void> _onDeleteExpense( Future<void> _onDeleteExpense(
DeleteExpense event, DeleteExpense event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
) async { ) async {
try { try {
await _expenseRepository.deleteExpense(event.expenseId); await _expenseRepository.deleteExpense(event.expenseId);
emit(const ExpenseOperationSuccess('pense supprimée avec succès')); emit(const ExpenseOperationSuccess('Expense deleted successfully'));
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur suppression expense: $e'); _errorService.logError('ExpenseBloc', 'Error deleting expense: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// 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
Future<void> _onMarkSplitAsPaid( Future<void> _onMarkSplitAsPaid(
MarkSplitAsPaid event, MarkSplitAsPaid event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
) async { ) async {
try { try {
await _expenseRepository.markSplitAsPaid(event.expenseId, event.userId); await _expenseRepository.markSplitAsPaid(event.expenseId, event.userId);
emit(const ExpenseOperationSuccess('Paiement marqué comme effectué')); emit(const ExpenseOperationSuccess('Payment marked as completed'));
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur mark split paid: $e'); _errorService.logError('ExpenseBloc', 'Error marking split as paid: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// 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
Future<void> _onArchiveExpense( Future<void> _onArchiveExpense(
ArchiveExpense event, ArchiveExpense event,
Emitter<ExpenseState> emit, Emitter<ExpenseState> emit,
) async { ) async {
try { try {
await _expenseRepository.archiveExpense(event.expenseId); await _expenseRepository.archiveExpense(event.expenseId);
emit(const ExpenseOperationSuccess('pense archivée avec succès')); emit(const ExpenseOperationSuccess('Expense archived successfully'));
} catch (e) { } catch (e) {
_errorService.logError('ExpenseBloc', 'Erreur archivage expense: $e'); _errorService.logError('ExpenseBloc', 'Error archiving expense: $e');
emit(ExpenseError(e.toString())); emit(ExpenseError(e.toString()));
} }
} }
/// Cleans up resources when the bloc is closed.
///
/// Cancels the expense stream subscription to prevent memory leaks
/// and ensure proper disposal of resources.
@override @override
Future<void> close() { Future<void> close() {
_expensesSubscription?.cancel(); _expensesSubscription?.cancel();

View File

@@ -2,26 +2,45 @@ import 'package:equatable/equatable.dart';
import '../../models/expense.dart'; import '../../models/expense.dart';
import 'dart:io'; import 'dart:io';
/// Abstract base class for all expense-related events.
///
/// This class extends [Equatable] to enable value equality for event comparison.
/// All expense events in the application should inherit from this class.
abstract class ExpenseEvent extends Equatable { abstract class ExpenseEvent extends Equatable {
/// Creates a new [ExpenseEvent].
const ExpenseEvent(); const ExpenseEvent();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to load all expenses for a specific group.
///
/// This event triggers the loading of all expenses associated with
/// the specified group, setting up a stream to receive real-time updates.
class LoadExpensesByGroup extends ExpenseEvent { class LoadExpensesByGroup extends ExpenseEvent {
/// The ID of the group to load expenses for.
final String groupId; final String groupId;
/// Creates a [LoadExpensesByGroup] event for the specified [groupId].
const LoadExpensesByGroup(this.groupId); const LoadExpensesByGroup(this.groupId);
@override @override
List<Object?> get props => [groupId]; List<Object?> get props => [groupId];
} }
/// Event to create a new expense.
///
/// This event is dispatched when a user wants to add a new expense
/// to a group, optionally including a receipt image.
class CreateExpense extends ExpenseEvent { class CreateExpense extends ExpenseEvent {
/// The expense data to create.
final Expense expense; final Expense expense;
/// Optional receipt image file to upload with the expense.
final File? receiptImage; final File? receiptImage;
/// Creates a [CreateExpense] event with the expense data and optional receipt.
const CreateExpense({ const CreateExpense({
required this.expense, required this.expense,
this.receiptImage, this.receiptImage,
@@ -31,10 +50,18 @@ class CreateExpense extends ExpenseEvent {
List<Object?> get props => [expense, receiptImage]; List<Object?> get props => [expense, receiptImage];
} }
/// Event to update an existing expense.
///
/// This event is dispatched when a user modifies an existing expense,
/// optionally changing the receipt image.
class UpdateExpense extends ExpenseEvent { class UpdateExpense extends ExpenseEvent {
/// The updated expense data.
final Expense expense; final Expense expense;
/// Optional new receipt image file to replace the existing one.
final File? newReceiptImage; final File? newReceiptImage;
/// Creates an [UpdateExpense] event with updated expense data.
const UpdateExpense({ const UpdateExpense({
required this.expense, required this.expense,
this.newReceiptImage, this.newReceiptImage,
@@ -44,19 +71,33 @@ class UpdateExpense extends ExpenseEvent {
List<Object?> get props => [expense, newReceiptImage]; List<Object?> get props => [expense, newReceiptImage];
} }
/// Event to delete an expense.
///
/// This event is dispatched when a user wants to permanently
/// remove an expense from the group.
class DeleteExpense extends ExpenseEvent { class DeleteExpense extends ExpenseEvent {
/// The ID of the expense to delete.
final String expenseId; final String expenseId;
/// Creates a [DeleteExpense] event for the specified [expenseId].
const DeleteExpense(this.expenseId); const DeleteExpense(this.expenseId);
@override @override
List<Object?> get props => [expenseId]; List<Object?> get props => [expenseId];
} }
/// Event to mark a user's split of an expense as paid.
///
/// This event is used when a user has paid their portion of
/// a shared expense to the person who originally paid for it.
class MarkSplitAsPaid extends ExpenseEvent { class MarkSplitAsPaid extends ExpenseEvent {
/// The ID of the expense containing the split.
final String expenseId; final String expenseId;
/// The ID of the user whose split is being marked as paid.
final String userId; final String userId;
/// Creates a [MarkSplitAsPaid] event for the specified expense and user.
const MarkSplitAsPaid({ const MarkSplitAsPaid({
required this.expenseId, required this.expenseId,
required this.userId, required this.userId,
@@ -66,20 +107,33 @@ class MarkSplitAsPaid extends ExpenseEvent {
List<Object?> get props => [expenseId, userId]; List<Object?> get props => [expenseId, userId];
} }
/// Event to archive an expense.
///
/// This event moves an expense to an archived state, hiding it
/// from the main expense list while preserving it for history.
class ArchiveExpense extends ExpenseEvent { class ArchiveExpense extends ExpenseEvent {
/// The ID of the expense to archive.
final String expenseId; final String expenseId;
/// Creates an [ArchiveExpense] event for the specified [expenseId].
const ArchiveExpense(this.expenseId); const ArchiveExpense(this.expenseId);
@override @override
List<Object?> get props => [expenseId]; List<Object?> get props => [expenseId];
} }
// Événement privé pour les mises à jour du stream /// Internal event for handling expense stream updates.
///
/// This is a private event used internally by the bloc to handle
/// real-time updates from the Firestore stream.
class ExpensesUpdated extends ExpenseEvent { class ExpensesUpdated extends ExpenseEvent {
/// The updated list of expenses from the stream.
final List<Expense> expenses; final List<Expense> expenses;
/// Optional error message if the stream encountered an error.
final String? error; final String? error;
/// Creates an [ExpensesUpdated] event with the expense list and optional error.
const ExpensesUpdated(this.expenses, {this.error}); const ExpensesUpdated(this.expenses, {this.error});
@override @override

View File

@@ -1,21 +1,47 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/expense.dart'; import '../../models/expense.dart';
/// Abstract base class for all expense-related states.
///
/// This class extends [Equatable] to enable value equality for state comparison.
/// All expense states in the application should inherit from this class.
abstract class ExpenseState extends Equatable { abstract class ExpenseState extends Equatable {
/// Creates a new [ExpenseState].
const ExpenseState(); const ExpenseState();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Initial state of the expense bloc.
///
/// This state represents the initial state before any expense
/// operations have been performed.
class ExpenseInitial extends ExpenseState {} class ExpenseInitial extends ExpenseState {}
/// State indicating that an expense operation is in progress.
///
/// This state is used to show loading indicators during expense
/// operations like loading, creating, updating, or deleting expenses.
class ExpenseLoading extends ExpenseState {} class ExpenseLoading extends ExpenseState {}
/// State indicating that expenses have been successfully loaded.
///
/// This state contains the list of expenses for a group and
/// exchange rates for currency conversion calculations.
class ExpensesLoaded extends ExpenseState { class ExpensesLoaded extends ExpenseState {
/// List of expenses for the current group.
final List<Expense> expenses; final List<Expense> expenses;
/// Exchange rates for currency conversion.
///
/// Maps currency codes to their exchange rates relative to EUR.
/// Used for converting different currencies to a common base for calculations.
final Map<String, double> exchangeRates; final Map<String, double> exchangeRates;
/// Creates an [ExpensesLoaded] state with expenses and exchange rates.
///
/// [exchangeRates] defaults to common rates if not provided.
const ExpensesLoaded({ const ExpensesLoaded({
required this.expenses, required this.expenses,
this.exchangeRates = const {'EUR': 1.0, 'USD': 0.85, 'GBP': 1.15}, this.exchangeRates = const {'EUR': 1.0, 'USD': 0.85, 'GBP': 1.15},
@@ -25,18 +51,30 @@ class ExpensesLoaded extends ExpenseState {
List<Object?> get props => [expenses, exchangeRates]; List<Object?> get props => [expenses, exchangeRates];
} }
/// State indicating that an expense operation has completed successfully.
///
/// This state is used to show success messages after operations like
/// creating, updating, deleting, or archiving expenses.
class ExpenseOperationSuccess extends ExpenseState { class ExpenseOperationSuccess extends ExpenseState {
/// Success message to display to the user.
final String message; final String message;
/// Creates an [ExpenseOperationSuccess] state with the given [message].
const ExpenseOperationSuccess(this.message); const ExpenseOperationSuccess(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
/// State indicating that an expense operation has failed.
///
/// This state contains an error message that can be displayed to the user
/// when expense operations fail.
class ExpenseError extends ExpenseState { class ExpenseError extends ExpenseState {
/// The error message describing what went wrong.
final String message; final String message;
/// Creates an [ExpenseError] state with the given error [message].
const ExpenseError(this.message); const ExpenseError(this.message);
@override @override

View File

@@ -1,3 +1,36 @@
/// A BLoC (Business Logic Component) that manages group-related operations.
///
/// This bloc handles all group operations including creation, updates, member management,
/// and loading groups for users or trips. It provides real-time updates through streams
/// and manages the relationship between users, groups, and trips.
///
/// The bloc processes these main events:
/// - [LoadGroupsByUserId]: Loads all groups for a specific user with real-time updates
/// - [LoadGroupsByTrip]: Loads the group associated with a specific trip
/// - [CreateGroup]: Creates a new group without members
/// - [CreateGroupWithMembers]: Creates a new group with initial members
/// - [AddMemberToGroup]: Adds a member to an existing group
/// - [RemoveMemberFromGroup]: Removes a member from a group
/// - [UpdateGroup]: Updates group information
/// - [DeleteGroup]: Deletes a group
///
/// Dependencies:
/// - [GroupRepository]: Repository for group data operations
/// - [ErrorService]: Service for error logging and handling
///
/// Example usage:
/// ```dart
/// final groupBloc = GroupBloc(groupRepository);
///
/// // Load groups for a user
/// groupBloc.add(LoadGroupsByUserId('userId123'));
///
/// // Create a new group with members
/// groupBloc.add(CreateGroupWithMembers(
/// group: newGroup,
/// members: [member1, member2],
/// ));
/// ```
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';
@@ -6,11 +39,24 @@ import 'group_state.dart';
import '../../repositories/group_repository.dart'; import '../../repositories/group_repository.dart';
import '../../models/group.dart'; import '../../models/group.dart';
/// BLoC that manages group-related operations and state.
class GroupBloc extends Bloc<GroupEvent, GroupState> { class GroupBloc extends Bloc<GroupEvent, GroupState> {
/// Repository for group data operations
final GroupRepository _repository; final GroupRepository _repository;
/// Subscription to group stream for real-time updates
StreamSubscription? _groupsSubscription; StreamSubscription? _groupsSubscription;
/// Service for error handling and logging
final _errorService = ErrorService(); final _errorService = ErrorService();
/// Constructor for GroupBloc.
///
/// Initializes the bloc with the group repository and sets up event handlers
/// for all group-related operations.
///
/// Args:
/// [_repository]: Repository for group data operations
GroupBloc(this._repository) : super(GroupInitial()) { GroupBloc(this._repository) : super(GroupInitial()) {
on<LoadGroupsByUserId>(_onLoadGroupsByUserId); on<LoadGroupsByUserId>(_onLoadGroupsByUserId);
on<_GroupsUpdated>(_onGroupsUpdated); on<_GroupsUpdated>(_onGroupsUpdated);
@@ -23,6 +69,14 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
on<DeleteGroup>(_onDeleteGroup); on<DeleteGroup>(_onDeleteGroup);
} }
/// Handles [LoadGroupsByUserId] events.
///
/// Loads all groups for a specific user with real-time updates via stream subscription.
/// Cancels any existing subscription before creating a new one to prevent memory leaks.
///
/// Args:
/// [event]: The LoadGroupsByUserId event containing the user ID
/// [emit]: State emitter function
Future<void> _onLoadGroupsByUserId( Future<void> _onLoadGroupsByUserId(
LoadGroupsByUserId event, LoadGroupsByUserId event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
@@ -44,6 +98,14 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
} }
} }
/// Handles [_GroupsUpdated] events.
///
/// Processes real-time updates from the group stream, either emitting
/// the updated group list or an error state if the stream encountered an error.
///
/// Args:
/// [event]: The _GroupsUpdated event containing groups or error information
/// [emit]: State emitter function
Future<void> _onGroupsUpdated( Future<void> _onGroupsUpdated(
_GroupsUpdated event, _GroupsUpdated event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
@@ -56,6 +118,14 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
} }
} }
/// Handles [LoadGroupsByTrip] events.
///
/// Loads the group associated with a specific trip. Since each trip typically
/// has one primary group, this returns a single group or an empty list.
///
/// Args:
/// [event]: The LoadGroupsByTrip event containing the trip ID
/// [emit]: State emitter function
Future<void> _onLoadGroupsByTrip( Future<void> _onLoadGroupsByTrip(
LoadGroupsByTrip event, LoadGroupsByTrip event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
@@ -73,6 +143,14 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
} }
} }
/// Handles [CreateGroup] events.
///
/// Creates a new group without any initial members. The group creator
/// can add members later using AddMemberToGroup events.
///
/// Args:
/// [event]: The CreateGroup event containing the group data
/// [emit]: State emitter function
Future<void> _onCreateGroup( Future<void> _onCreateGroup(
CreateGroup event, CreateGroup event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
@@ -84,12 +162,21 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
members: [], members: [],
); );
emit(GroupCreated(groupId: groupId)); emit(GroupCreated(groupId: groupId));
emit(const GroupOperationSuccess('Groupe créé avec succès')); emit(const GroupOperationSuccess('Group created successfully'));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de la création: $e')); emit(GroupError('Error during creation: $e'));
} }
} }
/// Handles [CreateGroupWithMembers] events.
///
/// Creates a new group with an initial set of members. This is useful
/// for setting up complete groups in one operation, such as when
/// planning a trip with known participants.
///
/// Args:
/// [event]: The CreateGroupWithMembers event containing group data and member list
/// [emit]: State emitter function
Future<void> _onCreateGroupWithMembers( Future<void> _onCreateGroupWithMembers(
CreateGroupWithMembers event, CreateGroupWithMembers event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
@@ -102,58 +189,94 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
); );
emit(GroupCreated(groupId: groupId)); emit(GroupCreated(groupId: groupId));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de la création: $e')); emit(GroupError('Error during creation: $e'));
} }
} }
/// Handles [AddMemberToGroup] events.
///
/// Adds a new member to an existing group. The member will be able to
/// participate in group expenses and access group features.
///
/// Args:
/// [event]: The AddMemberToGroup event containing group ID and member data
/// [emit]: State emitter function
Future<void> _onAddMemberToGroup( Future<void> _onAddMemberToGroup(
AddMemberToGroup event, AddMemberToGroup event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
) async { ) async {
try { try {
await _repository.addMember(event.groupId, event.member); await _repository.addMember(event.groupId, event.member);
emit(const GroupOperationSuccess('Membre ajouté')); emit(const GroupOperationSuccess('Member added'));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de l\'ajout: $e')); emit(GroupError('Error during addition: $e'));
} }
} }
/// Handles [RemoveMemberFromGroup] events.
///
/// Removes a member from a group. This will affect expense calculations
/// and the member will no longer have access to group features.
///
/// Args:
/// [event]: The RemoveMemberFromGroup event containing group ID and user ID
/// [emit]: State emitter function
Future<void> _onRemoveMemberFromGroup( Future<void> _onRemoveMemberFromGroup(
RemoveMemberFromGroup event, RemoveMemberFromGroup event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
) async { ) async {
try { try {
await _repository.removeMember(event.groupId, event.userId); await _repository.removeMember(event.groupId, event.userId);
emit(const GroupOperationSuccess('Membre supprimé')); emit(const GroupOperationSuccess('Member removed'));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de la suppression: $e')); emit(GroupError('Error during removal: $e'));
} }
} }
/// Handles [UpdateGroup] events.
///
/// Updates group information such as name, description, or settings.
/// Member lists are managed through separate add/remove member events.
///
/// Args:
/// [event]: The UpdateGroup event containing group ID and updated group data
/// [emit]: State emitter function
Future<void> _onUpdateGroup( Future<void> _onUpdateGroup(
UpdateGroup event, UpdateGroup event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
) async { ) async {
try { try {
await _repository.updateGroup(event.groupId, event.group); await _repository.updateGroup(event.groupId, event.group);
emit(const GroupOperationSuccess('Groupe mis à jour')); emit(const GroupOperationSuccess('Group updated'));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de la mise à jour: $e')); emit(GroupError('Error during update: $e'));
} }
} }
/// Handles [DeleteGroup] events.
///
/// Permanently deletes a group and all associated data. This action
/// cannot be undone and will affect all group members and expenses.
///
/// Args:
/// [event]: The DeleteGroup event containing the trip ID to delete
/// [emit]: State emitter function
Future<void> _onDeleteGroup( Future<void> _onDeleteGroup(
DeleteGroup event, DeleteGroup event,
Emitter<GroupState> emit, Emitter<GroupState> emit,
) async { ) async {
try { try {
await _repository.deleteGroup(event.tripId); await _repository.deleteGroup(event.tripId);
emit(const GroupOperationSuccess('Groupe supprimé')); emit(const GroupOperationSuccess('Group deleted'));
} catch (e) { } catch (e) {
emit(GroupError('Erreur lors de la suppression: $e')); emit(GroupError('Error during deletion: $e'));
} }
} }
/// Cleans up resources when the bloc is closed.
///
/// Cancels the group stream subscription to prevent memory leaks
/// and ensure proper disposal of resources.
@override @override
Future<void> close() { Future<void> close() {
_groupsSubscription?.cancel(); _groupsSubscription?.cancel();
@@ -161,10 +284,22 @@ class GroupBloc extends Bloc<GroupEvent, GroupState> {
} }
} }
/// Private event for handling real-time group updates from streams.
///
/// This internal event is used to process updates from the group stream
/// subscription and emit appropriate states based on the received data.
class _GroupsUpdated extends GroupEvent { class _GroupsUpdated extends GroupEvent {
/// List of groups received from the stream
final List<Group> groups; final List<Group> groups;
/// Error message if the stream encountered an error
final String? error; final String? error;
/// Creates a _GroupsUpdated event.
///
/// Args:
/// [groups]: List of groups from the stream update
/// [error]: Optional error message if stream failed
const _GroupsUpdated(this.groups, {this.error}); const _GroupsUpdated(this.groups, {this.error});
@override @override

View File

@@ -1,7 +1,25 @@
/// Events for group-related operations in the GroupBloc.
///
/// This file defines all possible events that can be dispatched to the GroupBloc
/// to trigger group-related state changes and operations such as loading groups,
/// creating groups, managing members, and performing CRUD operations.
///
/// Event Categories:
/// - **Loading Events**: LoadGroupsByUserId, LoadGroupsByTrip
/// - **Creation Events**: CreateGroup, CreateGroupWithMembers
/// - **Member Management**: AddMemberToGroup, RemoveMemberFromGroup
/// - **Modification Events**: UpdateGroup, DeleteGroup
///
/// All events extend [GroupEvent] and implement [Equatable] for proper
/// equality comparison in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/group.dart'; import '../../models/group.dart';
import '../../models/group_member.dart'; import '../../models/group_member.dart';
/// Base class for all group-related events.
///
/// All group events must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class GroupEvent extends Equatable { abstract class GroupEvent extends Equatable {
const GroupEvent(); const GroupEvent();
@@ -9,41 +27,96 @@ abstract class GroupEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
// NOUVEAU : Charger les groupes par userId /// Event to load all groups associated with a specific user.
///
/// This is the primary method for loading groups, as it retrieves all groups
/// where the user is a member, providing a comprehensive view of the user's
/// group memberships across different trips and activities.
///
/// Args:
/// [userId]: The unique identifier of the user whose groups should be loaded
class LoadGroupsByUserId extends GroupEvent { class LoadGroupsByUserId extends GroupEvent {
/// The unique identifier of the user
final String userId; final String userId;
/// Creates a LoadGroupsByUserId event.
///
/// Args:
/// [userId]: The user ID to load groups for
const LoadGroupsByUserId(this.userId); const LoadGroupsByUserId(this.userId);
@override @override
List<Object?> get props => [userId]; List<Object?> get props => [userId];
} }
// Charger les groupes d'un voyage (conservé pour compatibilité) // Load groups for a trip (maintained for compatibility)
/// Event to load groups associated with a specific trip.
///
/// This event is maintained for backward compatibility and specific use cases
/// where you need to load groups within the context of a particular trip.
/// Most trips have one primary group, but some may have multiple sub-groups.
///
/// Args:
/// [tripId]: The unique identifier of the trip whose groups should be loaded
class LoadGroupsByTrip extends GroupEvent { class LoadGroupsByTrip extends GroupEvent {
/// The unique identifier of the trip
final String tripId; final String tripId;
/// Creates a LoadGroupsByTrip event.
///
/// Args:
/// [tripId]: The trip ID to load groups for
const LoadGroupsByTrip(this.tripId); const LoadGroupsByTrip(this.tripId);
@override @override
List<Object?> get props => [tripId]; List<Object?> get props => [tripId];
} }
// Créer un groupe simple // Create a simple group
/// Event to create a new group without any initial members.
///
/// This creates a basic group structure that can be populated with members
/// later using AddMemberToGroup events. Useful when setting up a group
/// before knowing all participants.
///
/// Args:
/// [group]: The group object containing basic information (name, description, etc.)
class CreateGroup extends GroupEvent { class CreateGroup extends GroupEvent {
/// The group to be created
final Group group; final Group group;
/// Creates a CreateGroup event.
///
/// Args:
/// [group]: The group object to create
const CreateGroup(this.group); const CreateGroup(this.group);
@override @override
List<Object?> get props => [group]; List<Object?> get props => [group];
} }
// Créer un groupe avec ses membres // Create a group with its members
/// Event to create a new group with an initial set of members.
///
/// This is the preferred method for group creation when you know all or most
/// of the participants upfront. It creates the group and adds all specified
/// members in a single operation, ensuring data consistency.
///
/// Args:
/// [group]: The group object containing basic information
/// [members]: List of initial members to add to the group
class CreateGroupWithMembers extends GroupEvent { class CreateGroupWithMembers extends GroupEvent {
/// The group to be created
final Group group; final Group group;
/// Initial members to add to the group
final List<GroupMember> members; final List<GroupMember> members;
/// Creates a CreateGroupWithMembers event.
///
/// Args:
/// [group]: The group object to create
/// [members]: List of initial group members
const CreateGroupWithMembers({ const CreateGroupWithMembers({
required this.group, required this.group,
required this.members, required this.members,
@@ -53,43 +126,107 @@ class CreateGroupWithMembers extends GroupEvent {
List<Object?> get props => [group, members]; List<Object?> get props => [group, members];
} }
// Ajouter un membre // Add a member
/// Event to add a new member to an existing group.
///
/// This allows for dynamic group expansion by adding users to groups after
/// creation. The new member will gain access to group features like expenses,
/// messages, and shared resources.
///
/// Args:
/// [groupId]: The unique identifier of the target group
/// [member]: The group member object containing user information and role
class AddMemberToGroup extends GroupEvent { class AddMemberToGroup extends GroupEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The member to add to the group
final GroupMember member; final GroupMember member;
/// Creates an AddMemberToGroup event.
///
/// Args:
/// [groupId]: The group ID to add the member to
/// [member]: The group member to add
const AddMemberToGroup(this.groupId, this.member); const AddMemberToGroup(this.groupId, this.member);
@override @override
List<Object?> get props => [groupId, member]; List<Object?> get props => [groupId, member];
} }
// Supprimer un membre // Remove a member
/// Event to remove a member from a group.
///
/// This removes a user from the group, revoking their access to group features.
/// The removal will affect expense calculations and the user will no longer
/// receive group-related notifications or updates.
///
/// Args:
/// [groupId]: The unique identifier of the group
/// [userId]: The unique identifier of the user to remove
class RemoveMemberFromGroup extends GroupEvent { class RemoveMemberFromGroup extends GroupEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The unique identifier of the user to remove
final String userId; final String userId;
/// Creates a RemoveMemberFromGroup event.
///
/// Args:
/// [groupId]: The group ID to remove the member from
/// [userId]: The user ID to remove
const RemoveMemberFromGroup(this.groupId, this.userId); const RemoveMemberFromGroup(this.groupId, this.userId);
@override @override
List<Object?> get props => [groupId, userId]; List<Object?> get props => [groupId, userId];
} }
// Mettre à jour un groupe // Update a group
/// Event to update an existing group's information.
///
/// This allows modification of group properties such as name, description,
/// settings, or other metadata. Member management is handled through
/// separate add/remove member events.
///
/// Args:
/// [groupId]: The unique identifier of the group to update
/// [group]: The updated group object with new information
class UpdateGroup extends GroupEvent { class UpdateGroup extends GroupEvent {
/// The unique identifier of the group to update
final String groupId; final String groupId;
/// The updated group object
final Group group; final Group group;
/// Creates an UpdateGroup event.
///
/// Args:
/// [groupId]: The group ID to update
/// [group]: The updated group object
const UpdateGroup(this.groupId, this.group); const UpdateGroup(this.groupId, this.group);
@override @override
List<Object?> get props => [groupId, group]; List<Object?> get props => [groupId, group];
} }
// Supprimer un groupe // Delete a group
/// Event to permanently delete a group.
///
/// This is a destructive operation that removes the group and all associated
/// data. This action cannot be undone and will affect all group members.
/// Consider archiving instead of deleting for historical records.
///
/// Args:
/// [tripId]: The unique identifier of the trip whose group should be deleted
class DeleteGroup extends GroupEvent { class DeleteGroup extends GroupEvent {
/// The unique identifier of the trip (used to identify the group)
final String tripId; final String tripId;
/// Creates a DeleteGroup event.
///
/// Args:
/// [tripId]: The trip ID whose group should be deleted
const DeleteGroup(this.tripId); const DeleteGroup(this.tripId);
@override @override

View File

@@ -1,6 +1,24 @@
/// States for group-related operations in the GroupBloc.
///
/// This file defines all possible states that the GroupBloc can emit in response
/// to group-related events. The states represent different phases of group
/// operations including loading, success, error, and data states.
///
/// State Categories:
/// - **Initial State**: GroupInitial
/// - **Loading States**: GroupLoading
/// - **Success States**: GroupsLoaded, GroupLoaded, GroupCreated, GroupOperationSuccess
/// - **Error States**: GroupError
///
/// All states extend [GroupState] and implement [Equatable] for proper
/// equality comparison and state change detection in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/group.dart'; import '../../models/group.dart';
/// Base class for all group-related states.
///
/// All group states must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class GroupState extends Equatable { abstract class GroupState extends Equatable {
const GroupState(); const GroupState();
@@ -8,54 +26,132 @@ abstract class GroupState extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
// État initial // Initial state
/// The initial state of the GroupBloc before any operations are performed.
///
/// This is the default state when the bloc is first created and represents
/// a clean slate with no group data loaded or operations in progress.
class GroupInitial extends GroupState {} class GroupInitial extends GroupState {}
// Chargement // Loading
/// State indicating that a group operation is currently in progress.
///
/// This state is emitted when the bloc is performing operations like
/// loading groups, creating groups, or updating group information.
/// UI components can use this state to show loading indicators.
class GroupLoading extends GroupState {} class GroupLoading extends GroupState {}
// Groupes chargés // Groups loaded
/// State containing a list of successfully loaded groups.
///
/// This state is emitted when groups have been successfully retrieved
/// from the repository, either through initial loading or real-time updates.
/// The UI can use this data to display the list of available groups.
///
/// Properties:
/// [groups]: List of Group objects that were loaded
class GroupsLoaded extends GroupState { class GroupsLoaded extends GroupState {
/// The list of loaded groups
final List<Group> groups; final List<Group> groups;
/// Creates a GroupsLoaded state.
///
/// Args:
/// [groups]: The list of groups to include in this state
const GroupsLoaded(this.groups); const GroupsLoaded(this.groups);
@override @override
List<Object?> get props => [groups]; List<Object?> get props => [groups];
} }
/// Alternative state for loaded groups (used in some contexts).
///
/// This state serves a similar purpose to GroupsLoaded but may be used
/// in different contexts or for backward compatibility.
///
/// Properties:
/// [groups]: List of Group objects that were loaded
class GroupLoaded extends GroupState { class GroupLoaded extends GroupState {
/// The list of loaded groups
final List<Group> groups; final List<Group> groups;
/// Creates a GroupLoaded state.
///
/// Args:
/// [groups]: The list of groups to include in this state
const GroupLoaded(this.groups); const GroupLoaded(this.groups);
} }
/// State indicating successful group creation.
///
/// This state is emitted when a new group has been successfully created.
/// It contains the ID of the newly created group and an optional success message
/// that can be displayed to the user.
///
/// Properties:
/// [groupId]: The unique identifier of the newly created group
/// [message]: A success message to display to the user
class GroupCreated extends GroupState { class GroupCreated extends GroupState {
/// The unique identifier of the newly created group
final String groupId; final String groupId;
/// Success message for the user
final String message; final String message;
/// Creates a GroupCreated state.
///
/// Args:
/// [groupId]: The ID of the newly created group
/// [message]: Optional success message (defaults to "Group created successfully")
const GroupCreated({ const GroupCreated({
required this.groupId, required this.groupId,
this.message = 'Groupe créé avec succès', this.message = 'Group created successfully',
}); });
@override @override
List<Object?> get props => [groupId, message]; List<Object?> get props => [groupId, message];
} }
// Succès d'une opération // Operation success
/// State indicating successful completion of a group operation.
///
/// This state is emitted when operations like updating, deleting, or
/// member management have completed successfully. It contains a message
/// that can be displayed to inform the user of the successful operation.
///
/// Properties:
/// [message]: A success message describing the completed operation
class GroupOperationSuccess extends GroupState { class GroupOperationSuccess extends GroupState {
/// The success message to display to the user
final String message; final String message;
/// Creates a GroupOperationSuccess state.
///
/// Args:
/// [message]: The success message to display
const GroupOperationSuccess(this.message); const GroupOperationSuccess(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
// Erreur // Error
/// State indicating that a group operation has failed.
///
/// This state is emitted when any group operation encounters an error.
/// It contains an error message that can be displayed to the user to
/// explain what went wrong.
///
/// Properties:
/// [message]: An error message describing what went wrong
class GroupError extends GroupState { class GroupError extends GroupState {
/// The error message to display to the user
final String message; final String message;
/// Creates a GroupError state.
///
/// Args:
/// [message]: The error message to display
const GroupError(this.message); const GroupError(this.message);
@override @override

View File

@@ -1,3 +1,44 @@
/// A BLoC (Business Logic Component) that manages message-related operations for group chats.
///
/// This bloc handles all messaging functionality including sending, updating, deleting messages,
/// and managing reactions. It provides real-time updates through streams and manages the
/// relationship between users and group conversations.
///
/// The bloc processes these main events:
/// - [LoadMessages]: Loads messages for a group with real-time updates
/// - [SendMessage]: Sends a new message to a group chat
/// - [DeleteMessage]: Deletes a message from the chat
/// - [UpdateMessage]: Updates/edits an existing message
/// - [ReactToMessage]: Adds an emoji reaction to a message
/// - [RemoveReaction]: Removes a user's reaction from a message
///
/// Dependencies:
/// - [MessageService]: Service for message operations and business logic
/// - [MessageRepository]: Repository for message data operations
///
/// Example usage:
/// ```dart
/// final messageBloc = MessageBloc();
///
/// // Load messages for a group
/// messageBloc.add(LoadMessages('groupId123'));
///
/// // Send a message
/// messageBloc.add(SendMessage(
/// groupId: 'groupId123',
/// text: 'Hello everyone!',
/// senderId: 'userId123',
/// senderName: 'John Doe',
/// ));
///
/// // React to a message
/// messageBloc.add(ReactToMessage(
/// groupId: 'groupId123',
/// messageId: 'msgId456',
/// userId: 'userId123',
/// reaction: '👍',
/// ));
/// ```
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/message.dart'; import '../../models/message.dart';
@@ -6,10 +47,22 @@ import '../../repositories/message_repository.dart';
import 'message_event.dart'; import 'message_event.dart';
import 'message_state.dart'; import 'message_state.dart';
/// BLoC that manages message-related operations and real-time chat state.
class MessageBloc extends Bloc<MessageEvent, MessageState> { class MessageBloc extends Bloc<MessageEvent, MessageState> {
/// Service for message operations and business logic
final MessageService _messageService; final MessageService _messageService;
/// Subscription to message stream for real-time updates
StreamSubscription<List<Message>>? _messagesSubscription; StreamSubscription<List<Message>>? _messagesSubscription;
/// Constructor for MessageBloc.
///
/// Initializes the bloc with an optional message service. If no service is provided,
/// creates a default MessageService with MessageRepository. Sets up event handlers
/// for all message-related operations.
///
/// Args:
/// [messageService]: Optional service for message operations (auto-created if null)
MessageBloc({MessageService? messageService}) MessageBloc({MessageService? messageService})
: _messageService = messageService ?? MessageService( : _messageService = messageService ?? MessageService(
messageRepository: MessageRepository(), messageRepository: MessageRepository(),
@@ -24,6 +77,14 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
on<_MessagesUpdated>(_onMessagesUpdated); on<_MessagesUpdated>(_onMessagesUpdated);
} }
/// Handles [LoadMessages] events.
///
/// Loads messages for a specific group with real-time updates via stream subscription.
/// Cancels any existing subscription before creating a new one to prevent memory leaks.
///
/// Args:
/// [event]: The LoadMessages event containing the group ID
/// [emit]: State emitter function
Future<void> _onLoadMessages( Future<void> _onLoadMessages(
LoadMessages event, LoadMessages event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
@@ -39,11 +100,19 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
add(_MessagesUpdated(messages: messages, groupId: event.groupId)); add(_MessagesUpdated(messages: messages, groupId: event.groupId));
}, },
onError: (error) { onError: (error) {
add(_MessagesError('Erreur lors du chargement des messages: $error')); add(_MessagesError('Error loading messages: $error'));
}, },
); );
} }
/// Handles [_MessagesUpdated] events.
///
/// Processes real-time updates from the message stream, emitting the
/// updated message list with the associated group ID.
///
/// Args:
/// [event]: The _MessagesUpdated event containing messages and group ID
/// [emit]: State emitter function
void _onMessagesUpdated( void _onMessagesUpdated(
_MessagesUpdated event, _MessagesUpdated event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
@@ -51,12 +120,20 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
emit(MessagesLoaded(messages: event.messages, groupId: event.groupId)); emit(MessagesLoaded(messages: event.messages, groupId: event.groupId));
} }
/// Handles [SendMessage] events.
///
/// Sends a new message to a group chat. The stream subscription will
/// automatically update the UI with the new message, so no state is emitted here.
///
/// Args:
/// [event]: The SendMessage event containing message details
/// [emit]: State emitter function
Future<void> _onSendMessage( Future<void> _onSendMessage(
SendMessage event, SendMessage event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
) async { ) async {
try { try {
// Juste effectuer l'action, le stream mettra à jour // Just perform the action, the stream will update
await _messageService.sendMessage( await _messageService.sendMessage(
groupId: event.groupId, groupId: event.groupId,
text: event.text, text: event.text,
@@ -64,50 +141,74 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
senderName: event.senderName, senderName: event.senderName,
); );
} catch (e) { } catch (e) {
emit(MessageError('Erreur lors de l\'envoi du message: $e')); emit(MessageError('Error sending message: $e'));
} }
} }
/// Handles [DeleteMessage] events.
///
/// Deletes a message from the group chat. The Firestore stream will
/// automatically update the UI, so no state is emitted here unless there's an error.
///
/// Args:
/// [event]: The DeleteMessage event containing group ID and message ID
/// [emit]: State emitter function
Future<void> _onDeleteMessage( Future<void> _onDeleteMessage(
DeleteMessage event, DeleteMessage event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
) async { ) async {
try { try {
// Ne pas émettre d'état, juste effectuer l'action // Don't emit state, just perform the action
// Le stream Firestore mettra à jour automatiquement // The Firestore stream will update automatically
await _messageService.deleteMessage( await _messageService.deleteMessage(
groupId: event.groupId, groupId: event.groupId,
messageId: event.messageId, messageId: event.messageId,
); );
} catch (e) { } catch (e) {
emit(MessageError('Erreur lors de la suppression du message: $e')); emit(MessageError('Error deleting message: $e'));
} }
} }
/// Handles [UpdateMessage] events.
///
/// Updates/edits an existing message in the group chat. The Firestore stream will
/// automatically update the UI with the edited message, so no state is emitted here.
///
/// Args:
/// [event]: The UpdateMessage event containing message ID and new text
/// [emit]: State emitter function
Future<void> _onUpdateMessage( Future<void> _onUpdateMessage(
UpdateMessage event, UpdateMessage event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
) async { ) async {
try { try {
// Ne pas émettre d'état, juste effectuer l'action // Don't emit state, just perform the action
// Le stream Firestore mettra à jour automatiquement // The Firestore stream will update automatically
await _messageService.updateMessage( await _messageService.updateMessage(
groupId: event.groupId, groupId: event.groupId,
messageId: event.messageId, messageId: event.messageId,
newText: event.newText, newText: event.newText,
); );
} catch (e) { } catch (e) {
emit(MessageError('Erreur lors de la modification du message: $e')); emit(MessageError('Error updating message: $e'));
} }
} }
/// Handles [ReactToMessage] events.
///
/// Adds an emoji reaction to a message. The Firestore stream will
/// automatically update the UI with the new reaction, so no state is emitted here.
///
/// Args:
/// [event]: The ReactToMessage event containing message ID, user ID, and reaction
/// [emit]: State emitter function
Future<void> _onReactToMessage( Future<void> _onReactToMessage(
ReactToMessage event, ReactToMessage event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
) async { ) async {
try { try {
// Ne pas émettre d'état, juste effectuer l'action // Don't emit state, just perform the action
// Le stream Firestore mettra à jour automatiquement // The Firestore stream will update automatically
await _messageService.reactToMessage( await _messageService.reactToMessage(
groupId: event.groupId, groupId: event.groupId,
messageId: event.messageId, messageId: event.messageId,
@@ -115,27 +216,39 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
reaction: event.reaction, reaction: event.reaction,
); );
} catch (e) { } catch (e) {
emit(MessageError('Erreur lors de l\'ajout de la réaction: $e')); emit(MessageError('Error adding reaction: $e'));
} }
} }
/// Handles [RemoveReaction] events.
///
/// Removes a user's reaction from a message. The Firestore stream will
/// automatically update the UI with the removed reaction, so no state is emitted here.
///
/// Args:
/// [event]: The RemoveReaction event containing message ID and user ID
/// [emit]: State emitter function
Future<void> _onRemoveReaction( Future<void> _onRemoveReaction(
RemoveReaction event, RemoveReaction event,
Emitter<MessageState> emit, Emitter<MessageState> emit,
) async { ) async {
try { try {
// Ne pas émettre d'état, juste effectuer l'action // Don't emit state, just perform the action
// Le stream Firestore mettra à jour automatiquement // The Firestore stream will update automatically
await _messageService.removeReaction( await _messageService.removeReaction(
groupId: event.groupId, groupId: event.groupId,
messageId: event.messageId, messageId: event.messageId,
userId: event.userId, userId: event.userId,
); );
} catch (e) { } catch (e) {
emit(MessageError('Erreur lors de la suppression de la réaction: $e')); emit(MessageError('Error removing reaction: $e'));
} }
} }
/// Cleans up resources when the bloc is closed.
///
/// Cancels the message stream subscription to prevent memory leaks
/// and ensure proper disposal of resources.
@override @override
Future<void> close() { Future<void> close() {
_messagesSubscription?.cancel(); _messagesSubscription?.cancel();
@@ -143,11 +256,22 @@ class MessageBloc extends Bloc<MessageEvent, MessageState> {
} }
} }
// Events internes /// Private event for handling real-time message updates from streams.
///
/// This internal event is used to process updates from the message stream
/// subscription and emit appropriate states based on the received data.
class _MessagesUpdated extends MessageEvent { class _MessagesUpdated extends MessageEvent {
/// List of messages received from the stream
final List<Message> messages; final List<Message> messages;
/// Group ID associated with the messages
final String groupId; final String groupId;
/// Creates a _MessagesUpdated event.
///
/// Args:
/// [messages]: List of messages from the stream update
/// [groupId]: ID of the group these messages belong to
const _MessagesUpdated({ const _MessagesUpdated({
required this.messages, required this.messages,
required this.groupId, required this.groupId,
@@ -157,9 +281,18 @@ class _MessagesUpdated extends MessageEvent {
List<Object?> get props => [messages, groupId]; List<Object?> get props => [messages, groupId];
} }
/// Private event for handling message stream errors.
///
/// This internal event is used to process errors from the message stream
/// subscription and emit appropriate error states.
class _MessagesError extends MessageEvent { class _MessagesError extends MessageEvent {
/// Error message from the stream
final String error; final String error;
/// Creates a _MessagesError event.
///
/// Args:
/// [error]: Error message from the stream failure
const _MessagesError(this.error); const _MessagesError(this.error);
@override @override

View File

@@ -1,5 +1,25 @@
/// Events for message-related operations in the MessageBloc.
///
/// This file defines all possible events that can be dispatched to the MessageBloc
/// to trigger message-related state changes and operations such as loading messages,
/// sending messages, managing reactions, and performing CRUD operations on chat messages.
///
/// Event Categories:
/// - **Loading Events**: LoadMessages
/// - **Message Operations**: SendMessage, DeleteMessage, UpdateMessage
/// - **Reaction Management**: ReactToMessage, RemoveReaction
///
/// All events support real-time group chat functionality with features like
/// message editing, deletion, and emoji reactions.
///
/// All events extend [MessageEvent] and implement [Equatable] for proper
/// equality comparison in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Base class for all message-related events.
///
/// All message events must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class MessageEvent extends Equatable { abstract class MessageEvent extends Equatable {
const MessageEvent(); const MessageEvent();
@@ -7,21 +27,59 @@ abstract class MessageEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to load messages for a specific group with real-time updates.
///
/// This event initializes the message stream for a group, providing real-time
/// updates as new messages are sent, edited, or deleted. The stream will
/// continue until the bloc is closed or a new LoadMessages event is dispatched.
///
/// Args:
/// [groupId]: The unique identifier of the group whose messages should be loaded
class LoadMessages extends MessageEvent { class LoadMessages extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// Creates a LoadMessages event.
///
/// Args:
/// [groupId]: The group ID to load messages for
const LoadMessages(this.groupId); const LoadMessages(this.groupId);
@override @override
List<Object?> get props => [groupId]; List<Object?> get props => [groupId];
} }
/// Event to send a new message to a group chat.
///
/// This event creates and sends a new message to the specified group.
/// The message will be immediately visible to all group members through
/// the real-time message stream.
///
/// Args:
/// [groupId]: The unique identifier of the target group
/// [text]: The content of the message to send
/// [senderId]: The unique identifier of the user sending the message
/// [senderName]: The display name of the user sending the message
class SendMessage extends MessageEvent { class SendMessage extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The content of the message
final String text; final String text;
/// The unique identifier of the sender
final String senderId; final String senderId;
/// The display name of the sender
final String senderName; final String senderName;
/// Creates a SendMessage event.
///
/// Args:
/// [groupId]: The target group ID
/// [text]: The message content
/// [senderId]: The sender's user ID
/// [senderName]: The sender's display name
const SendMessage({ const SendMessage({
required this.groupId, required this.groupId,
required this.text, required this.text,
@@ -33,10 +91,27 @@ class SendMessage extends MessageEvent {
List<Object?> get props => [groupId, text, senderId, senderName]; List<Object?> get props => [groupId, text, senderId, senderName];
} }
/// Event to delete a message from the group chat.
///
/// This event permanently removes a message from the chat. The deletion
/// will be immediately reflected for all group members through the
/// real-time message stream. This action cannot be undone.
///
/// Args:
/// [groupId]: The unique identifier of the group containing the message
/// [messageId]: The unique identifier of the message to delete
class DeleteMessage extends MessageEvent { class DeleteMessage extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The unique identifier of the message to delete
final String messageId; final String messageId;
/// Creates a DeleteMessage event.
///
/// Args:
/// [groupId]: The group ID containing the message
/// [messageId]: The message ID to delete
const DeleteMessage({ const DeleteMessage({
required this.groupId, required this.groupId,
required this.messageId, required this.messageId,
@@ -46,11 +121,32 @@ class DeleteMessage extends MessageEvent {
List<Object?> get props => [groupId, messageId]; List<Object?> get props => [groupId, messageId];
} }
/// Event to update/edit an existing message in the group chat.
///
/// This event allows users to modify the content of a previously sent message.
/// The updated message will be immediately visible to all group members
/// through the real-time message stream, typically with an "edited" indicator.
///
/// Args:
/// [groupId]: The unique identifier of the group containing the message
/// [messageId]: The unique identifier of the message to update
/// [newText]: The new content for the message
class UpdateMessage extends MessageEvent { class UpdateMessage extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The unique identifier of the message to update
final String messageId; final String messageId;
/// The new content for the message
final String newText; final String newText;
/// Creates an UpdateMessage event.
///
/// Args:
/// [groupId]: The group ID containing the message
/// [messageId]: The message ID to update
/// [newText]: The new message content
const UpdateMessage({ const UpdateMessage({
required this.groupId, required this.groupId,
required this.messageId, required this.messageId,
@@ -61,12 +157,37 @@ class UpdateMessage extends MessageEvent {
List<Object?> get props => [groupId, messageId, newText]; List<Object?> get props => [groupId, messageId, newText];
} }
/// Event to add an emoji reaction to a message.
///
/// This event allows users to react to messages with emojis, providing
/// a quick way to express emotions or acknowledgment without sending
/// a full message. The reaction will be immediately visible to all group members.
///
/// Args:
/// [groupId]: The unique identifier of the group containing the message
/// [messageId]: The unique identifier of the message to react to
/// [userId]: The unique identifier of the user adding the reaction
/// [reaction]: The emoji or reaction string to add
class ReactToMessage extends MessageEvent { class ReactToMessage extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The unique identifier of the message
final String messageId; final String messageId;
/// The unique identifier of the user adding the reaction
final String userId; final String userId;
/// The emoji or reaction string
final String reaction; final String reaction;
/// Creates a ReactToMessage event.
///
/// Args:
/// [groupId]: The group ID containing the message
/// [messageId]: The message ID to react to
/// [userId]: The user ID adding the reaction
/// [reaction]: The emoji/reaction to add
const ReactToMessage({ const ReactToMessage({
required this.groupId, required this.groupId,
required this.messageId, required this.messageId,
@@ -78,11 +199,32 @@ class ReactToMessage extends MessageEvent {
List<Object?> get props => [groupId, messageId, userId, reaction]; List<Object?> get props => [groupId, messageId, userId, reaction];
} }
/// Event to remove a user's reaction from a message.
///
/// This event removes a previously added emoji reaction from a message.
/// Only the user who added the reaction can remove it. The removal will
/// be immediately reflected for all group members through the real-time stream.
///
/// Args:
/// [groupId]: The unique identifier of the group containing the message
/// [messageId]: The unique identifier of the message with the reaction
/// [userId]: The unique identifier of the user removing their reaction
class RemoveReaction extends MessageEvent { class RemoveReaction extends MessageEvent {
/// The unique identifier of the group
final String groupId; final String groupId;
/// The unique identifier of the message
final String messageId; final String messageId;
/// The unique identifier of the user removing the reaction
final String userId; final String userId;
/// Creates a RemoveReaction event.
///
/// Args:
/// [groupId]: The group ID containing the message
/// [messageId]: The message ID with the reaction
/// [userId]: The user ID removing the reaction
const RemoveReaction({ const RemoveReaction({
required this.groupId, required this.groupId,
required this.messageId, required this.messageId,

View File

@@ -1,6 +1,27 @@
/// States for message-related operations in the MessageBloc.
///
/// This file defines all possible states that the MessageBloc can emit in response
/// to message-related events. The states represent different phases of message
/// operations including loading, success, and error states for real-time chat functionality.
///
/// State Categories:
/// - **Initial State**: MessageInitial
/// - **Loading States**: MessageLoading
/// - **Success States**: MessagesLoaded
/// - **Error States**: MessageError
///
/// The states support real-time chat features including message streaming,
/// reactions, editing, and deletion within group conversations.
///
/// All states extend [MessageState] and implement [Equatable] for proper
/// equality comparison and state change detection in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/message.dart'; import '../../models/message.dart';
/// Base class for all message-related states.
///
/// All message states must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class MessageState extends Equatable { abstract class MessageState extends Equatable {
const MessageState(); const MessageState();
@@ -8,14 +29,41 @@ abstract class MessageState extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// The initial state of the MessageBloc before any operations are performed.
///
/// This is the default state when the bloc is first created and represents
/// a clean slate with no message data loaded or operations in progress.
class MessageInitial extends MessageState {} class MessageInitial extends MessageState {}
/// State indicating that a message operation is currently in progress.
///
/// This state is emitted when the bloc is performing operations like
/// loading messages or initializing the real-time message stream.
/// UI components can use this state to show loading indicators.
class MessageLoading extends MessageState {} class MessageLoading extends MessageState {}
/// State containing a list of successfully loaded messages for a group.
///
/// This state is emitted when messages have been successfully retrieved
/// from the repository, either through initial loading or real-time updates.
/// The state includes both the messages and the group ID for context.
/// The UI can use this data to display the chat conversation.
///
/// Properties:
/// [messages]: List of Message objects in the conversation
/// [groupId]: The unique identifier of the group these messages belong to
class MessagesLoaded extends MessageState { class MessagesLoaded extends MessageState {
/// The list of messages in the conversation
final List<Message> messages; final List<Message> messages;
/// The unique identifier of the group
final String groupId; final String groupId;
/// Creates a MessagesLoaded state.
///
/// Args:
/// [messages]: The list of messages to include in this state
/// [groupId]: The group ID these messages belong to
const MessagesLoaded({ const MessagesLoaded({
required this.messages, required this.messages,
required this.groupId, required this.groupId,
@@ -25,9 +73,23 @@ class MessagesLoaded extends MessageState {
List<Object?> get props => [messages, groupId]; List<Object?> get props => [messages, groupId];
} }
/// State indicating that a message operation has failed.
///
/// This state is emitted when any message operation encounters an error,
/// such as failing to load messages, send a message, or perform other
/// message-related operations. It contains an error message that can be
/// displayed to the user to explain what went wrong.
///
/// Properties:
/// [message]: An error message describing what went wrong
class MessageError extends MessageState { class MessageError extends MessageState {
/// The error message to display to the user
final String message; final String message;
/// Creates a MessageError state.
///
/// Args:
/// [message]: The error message to display
const MessageError(this.message); const MessageError(this.message);
@override @override

View File

@@ -4,23 +4,39 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'theme_event.dart'; import 'theme_event.dart';
import 'theme_state.dart'; import 'theme_state.dart';
/// BLoC for managing application theme preferences.
///
/// This BLoC handles theme-related events and manages the application's
/// theme mode (light, dark, or system). It persists theme preferences
/// using SharedPreferences for consistency across app sessions.
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> { class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
/// Creates a new [ThemeBloc] with default theme state.
///
/// Registers event handlers for theme changes and loading saved preferences.
ThemeBloc() : super(const ThemeState()) { ThemeBloc() : super(const ThemeState()) {
on<ThemeChanged>(_onThemeChanged); on<ThemeChanged>(_onThemeChanged);
on<ThemeLoadRequested>(_onThemeLoadRequested); on<ThemeLoadRequested>(_onThemeLoadRequested);
} }
/// Handles [ThemeChanged] events.
///
/// Updates the theme mode and persists the preference to local storage.
/// This ensures the theme choice is remembered across app restarts.
Future<void> _onThemeChanged( Future<void> _onThemeChanged(
ThemeChanged event, ThemeChanged event,
Emitter<ThemeState> emit, Emitter<ThemeState> emit,
) async { ) async {
emit(state.copyWith(themeMode: event.themeMode)); emit(state.copyWith(themeMode: event.themeMode));
// Sauvegarder la préférence // Save the theme preference to persistent storage
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString('themeMode', event.themeMode.toString()); await prefs.setString('themeMode', event.themeMode.toString());
} }
/// Handles [ThemeLoadRequested] events.
///
/// Loads the saved theme preference from SharedPreferences and applies it.
/// If no preference is saved, the theme remains as system default.
Future<void> _onThemeLoadRequested( Future<void> _onThemeLoadRequested(
ThemeLoadRequested event, ThemeLoadRequested event,
Emitter<ThemeState> emit, Emitter<ThemeState> emit,

View File

@@ -1,20 +1,35 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Abstract base class for all theme-related events.
///
/// This class extends [Equatable] to enable value equality for event comparison.
/// All theme events in the application should inherit from this class.
abstract class ThemeEvent extends Equatable { abstract class ThemeEvent extends Equatable {
/// Creates a new [ThemeEvent].
const ThemeEvent(); const ThemeEvent();
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to request a theme change.
///
/// This event is dispatched when the user wants to change the application theme
/// mode (light, dark, or system). The new theme mode is persisted and applied immediately.
class ThemeChanged extends ThemeEvent { class ThemeChanged extends ThemeEvent {
/// The new theme mode to apply.
final ThemeMode themeMode; final ThemeMode themeMode;
/// Creates a new [ThemeChanged] event with the specified [themeMode].
const ThemeChanged({required this.themeMode}); const ThemeChanged({required this.themeMode});
@override @override
List<Object?> get props => [themeMode]; List<Object?> get props => [themeMode];
} }
/// Event to request loading of saved theme preferences.
///
/// This event is typically dispatched when the app starts to restore
/// the user's previously selected theme preference.
class ThemeLoadRequested extends ThemeEvent {} class ThemeLoadRequested extends ThemeEvent {}

View File

@@ -1,15 +1,32 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// State class for theme management.
///
/// This class represents the current theme state of the application,
/// including the selected theme mode and provides utility methods
/// for theme-related operations.
class ThemeState extends Equatable { class ThemeState extends Equatable {
/// The current theme mode of the application.
final ThemeMode themeMode; final ThemeMode themeMode;
/// Creates a new [ThemeState] with the specified [themeMode].
///
/// Defaults to [ThemeMode.system] if no theme mode is provided.
const ThemeState({this.themeMode = ThemeMode.system}); const ThemeState({this.themeMode = ThemeMode.system});
/// Whether the current theme mode is explicitly set to dark.
///
/// Returns true only if the theme mode is [ThemeMode.dark].
/// Returns false for [ThemeMode.light] and [ThemeMode.system].
bool get isDarkMode { bool get isDarkMode {
return themeMode == ThemeMode.dark; return themeMode == ThemeMode.dark;
} }
/// Creates a copy of this state with optionally modified properties.
///
/// Allows updating the theme mode while preserving other state properties.
/// Useful for state transitions in the theme BLoC.
ThemeState copyWith({ThemeMode? themeMode}) { ThemeState copyWith({ThemeMode? themeMode}) {
return ThemeState( return ThemeState(
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,

View File

@@ -1,3 +1,40 @@
/// A BLoC (Business Logic Component) that manages trip-related operations.
///
/// This bloc handles all trip operations including creation, updates, deletion,
/// and loading trips for users. It provides real-time updates through streams
/// and manages the trip lifecycle with proper state transitions.
///
/// The bloc processes these main events:
/// - [LoadTripsByUserId]: Loads all trips for a specific user with real-time updates
/// - [TripCreateRequested]: Creates a new trip and reloads the user's trip list
/// - [TripUpdateRequested]: Updates an existing trip and refreshes the list
/// - [TripDeleteRequested]: Deletes a trip and refreshes the list
/// - [ResetTrips]: Resets the trip state and cancels subscriptions
///
/// Dependencies:
/// - [TripRepository]: Repository for trip data operations
///
/// State Management:
/// The bloc maintains the current user ID to enable automatic list refreshing
/// after operations like create, update, or delete. This ensures the UI stays
/// in sync with the latest data.
///
/// Example usage:
/// ```dart
/// final tripBloc = TripBloc(tripRepository);
///
/// // Load trips for a user
/// tripBloc.add(LoadTripsByUserId(userId: 'userId123'));
///
/// // Create a new trip
/// tripBloc.add(TripCreateRequested(trip: newTrip));
///
/// // Update a trip
/// tripBloc.add(TripUpdateRequested(trip: updatedTrip));
///
/// // Delete a trip
/// tripBloc.add(TripDeleteRequested(tripId: 'tripId456'));
/// ```
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/models/trip.dart'; import 'package:travel_mate/models/trip.dart';
@@ -5,12 +42,24 @@ import 'trip_event.dart';
import 'trip_state.dart'; import 'trip_state.dart';
import '../../repositories/trip_repository.dart'; import '../../repositories/trip_repository.dart';
/// BLoC that manages trip-related operations and state.
class TripBloc extends Bloc<TripEvent, TripState> { class TripBloc extends Bloc<TripEvent, TripState> {
/// Repository for trip data operations
final TripRepository _repository; final TripRepository _repository;
/// Subscription to trip stream for real-time updates
StreamSubscription? _tripsSubscription; StreamSubscription? _tripsSubscription;
/// Current user ID for automatic list refreshing after operations
String? _currentUserId; String? _currentUserId;
/// Constructor for TripBloc.
///
/// Initializes the bloc with the trip repository and sets up event handlers
/// for all trip-related operations.
///
/// Args:
/// [_repository]: Repository for trip data operations
TripBloc(this._repository) : super(TripInitial()) { TripBloc(this._repository) : super(TripInitial()) {
on<LoadTripsByUserId>(_onLoadTripsByUserId); on<LoadTripsByUserId>(_onLoadTripsByUserId);
on<TripCreateRequested>(_onTripCreateRequested); on<TripCreateRequested>(_onTripCreateRequested);
@@ -20,6 +69,15 @@ class TripBloc extends Bloc<TripEvent, TripState> {
on<ResetTrips>(_onResetTrips); on<ResetTrips>(_onResetTrips);
} }
/// Handles [LoadTripsByUserId] events.
///
/// Loads all trips for a specific user with real-time updates via stream subscription.
/// Stores the user ID for future automatic refreshing after operations and cancels
/// any existing subscription to prevent memory leaks.
///
/// Args:
/// [event]: The LoadTripsByUserId event containing the user ID
/// [emit]: State emitter function
Future<void> _onLoadTripsByUserId( Future<void> _onLoadTripsByUserId(
LoadTripsByUserId event, LoadTripsByUserId event,
Emitter<TripState> emit, Emitter<TripState> emit,
@@ -39,6 +97,14 @@ class TripBloc extends Bloc<TripEvent, TripState> {
); );
} }
/// Handles [_TripsUpdated] events.
///
/// Processes real-time updates from the trip stream and emits the
/// updated trip list to the UI.
///
/// Args:
/// [event]: The _TripsUpdated event containing the updated trip list
/// [emit]: State emitter function
void _onTripsUpdated( void _onTripsUpdated(
_TripsUpdated event, _TripsUpdated event,
Emitter<TripState> emit, Emitter<TripState> emit,
@@ -46,6 +112,15 @@ class TripBloc extends Bloc<TripEvent, TripState> {
emit(TripLoaded(event.trips)); emit(TripLoaded(event.trips));
} }
/// Handles [TripCreateRequested] events.
///
/// Creates a new trip and automatically refreshes the user's trip list
/// to show the newly created trip. Includes a delay to allow the creation
/// to complete before refreshing.
///
/// Args:
/// [event]: The TripCreateRequested event containing the trip data
/// [emit]: State emitter function
Future<void> _onTripCreateRequested( Future<void> _onTripCreateRequested(
TripCreateRequested event, TripCreateRequested event,
Emitter<TripState> emit, Emitter<TripState> emit,
@@ -63,27 +138,45 @@ class TripBloc extends Bloc<TripEvent, TripState> {
} }
} catch (e) { } catch (e) {
emit(TripError('Erreur lors de la création: $e')); emit(TripError('Error during creation: $e'));
} }
} }
/// Handles [TripUpdateRequested] events.
///
/// Updates an existing trip and automatically refreshes the user's trip list
/// to show the updated information. Includes a delay to allow the update
/// to complete before refreshing.
///
/// Args:
/// [event]: The TripUpdateRequested event containing the updated trip data
/// [emit]: State emitter function
Future<void> _onTripUpdateRequested( Future<void> _onTripUpdateRequested(
TripUpdateRequested event, TripUpdateRequested event,
Emitter<TripState> emit, Emitter<TripState> emit,
) async { ) async {
try { try {
await _repository.updateTrip(event.trip.id!, event.trip); await _repository.updateTrip(event.trip.id!, event.trip);
emit(const TripOperationSuccess('Voyage mis à jour avec succès')); emit(const TripOperationSuccess('Trip updated successfully'));
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (_currentUserId != null) { if (_currentUserId != null) {
add(LoadTripsByUserId(userId: _currentUserId!)); add(LoadTripsByUserId(userId: _currentUserId!));
} }
} catch (e) { } catch (e) {
emit(TripError('Erreur lors de la mise à jour: $e')); emit(TripError('Error during update: $e'));
} }
} }
/// Handles [TripDeleteRequested] events.
///
/// Deletes a trip and automatically refreshes the user's trip list
/// to remove the deleted trip from the UI. Includes a delay to allow
/// the deletion to complete before refreshing.
///
/// Args:
/// [event]: The TripDeleteRequested event containing the trip ID to delete
/// [emit]: State emitter function
Future<void> _onTripDeleteRequested( Future<void> _onTripDeleteRequested(
TripDeleteRequested event, TripDeleteRequested event,
Emitter<TripState> emit, Emitter<TripState> emit,
@@ -91,7 +184,7 @@ class TripBloc extends Bloc<TripEvent, TripState> {
try { try {
await _repository.deleteTrip(event.tripId); await _repository.deleteTrip(event.tripId);
emit(const TripOperationSuccess('Voyage supprimé avec succès')); emit(const TripOperationSuccess('Trip deleted successfully'));
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (_currentUserId != null) { if (_currentUserId != null) {
@@ -99,10 +192,19 @@ class TripBloc extends Bloc<TripEvent, TripState> {
} }
} catch (e) { } catch (e) {
emit(TripError('Erreur lors de la suppression: $e')); emit(TripError('Error during deletion: $e'));
} }
} }
/// Handles [ResetTrips] events.
///
/// Resets the trip state to initial and cleans up resources.
/// Cancels the trip stream subscription and clears the current user ID.
/// This is useful for user logout or when switching contexts.
///
/// Args:
/// [event]: The ResetTrips event
/// [emit]: State emitter function
Future<void> _onResetTrips( Future<void> _onResetTrips(
ResetTrips event, ResetTrips event,
Emitter<TripState> emit, Emitter<TripState> emit,
@@ -112,6 +214,10 @@ class TripBloc extends Bloc<TripEvent, TripState> {
emit(TripInitial()); emit(TripInitial());
} }
/// Cleans up resources when the bloc is closed.
///
/// Cancels the trip stream subscription to prevent memory leaks
/// and ensure proper disposal of resources.
@override @override
Future<void> close() { Future<void> close() {
_tripsSubscription?.cancel(); _tripsSubscription?.cancel();
@@ -119,9 +225,18 @@ class TripBloc extends Bloc<TripEvent, TripState> {
} }
} }
/// Private event for handling real-time trip updates from streams.
///
/// This internal event is used to process updates from the trip stream
/// subscription and emit appropriate states based on the received data.
class _TripsUpdated extends TripEvent { class _TripsUpdated extends TripEvent {
/// List of trips received from the stream
final List<Trip> trips; final List<Trip> trips;
/// Creates a _TripsUpdated event.
///
/// Args:
/// [trips]: List of trips from the stream update
const _TripsUpdated(this.trips); const _TripsUpdated(this.trips);
@override @override

View File

@@ -1,6 +1,26 @@
/// Events for trip-related operations in the TripBloc.
///
/// This file defines all possible events that can be dispatched to the TripBloc
/// to trigger trip-related state changes and operations such as loading trips,
/// creating trips, updating trip information, and performing CRUD operations.
///
/// Event Categories:
/// - **Loading Events**: LoadTripsByUserId
/// - **CRUD Operations**: TripCreateRequested, TripUpdateRequested, TripDeleteRequested
/// - **State Management**: ResetTrips
///
/// The events support complete trip lifecycle management with automatic
/// list refreshing after operations to maintain UI consistency.
///
/// All events extend [TripEvent] and implement [Equatable] for proper
/// equality comparison in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/trip.dart'; import '../../models/trip.dart';
/// Base class for all trip-related events.
///
/// All trip events must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class TripEvent extends Equatable { abstract class TripEvent extends Equatable {
const TripEvent(); const TripEvent();
@@ -8,40 +28,98 @@ abstract class TripEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Event to load all trips associated with a specific user.
///
/// This event retrieves all trips where the user is a participant or organizer,
/// providing a comprehensive view of the user's travel activities. The loading
/// uses real-time streams to keep the trip list updated automatically.
///
/// Args:
/// [userId]: The unique identifier of the user whose trips should be loaded
class LoadTripsByUserId extends TripEvent { class LoadTripsByUserId extends TripEvent {
/// The unique identifier of the user
final String userId; final String userId;
/// Creates a LoadTripsByUserId event.
///
/// Args:
/// [userId]: The user ID to load trips for
const LoadTripsByUserId({required this.userId}); const LoadTripsByUserId({required this.userId});
@override @override
List<Object?> get props => [userId]; List<Object?> get props => [userId];
} }
/// Event to request creation of a new trip.
///
/// This event creates a new trip with the provided information and automatically
/// refreshes the user's trip list to show the newly created trip. The operation
/// includes validation and proper error handling.
///
/// Args:
/// [trip]: The trip object containing all the trip information to create
class TripCreateRequested extends TripEvent { class TripCreateRequested extends TripEvent {
/// The trip object to create
final Trip trip; final Trip trip;
/// Creates a TripCreateRequested event.
///
/// Args:
/// [trip]: The trip object to create
const TripCreateRequested({required this.trip}); const TripCreateRequested({required this.trip});
@override @override
List<Object?> get props => [trip]; List<Object?> get props => [trip];
} }
/// Event to request updating an existing trip.
///
/// This event updates an existing trip with new information and automatically
/// refreshes the user's trip list to reflect the changes. The trip must have
/// a valid ID for the update operation to succeed.
///
/// Args:
/// [trip]: The trip object with updated information (must include valid ID)
class TripUpdateRequested extends TripEvent { class TripUpdateRequested extends TripEvent {
/// The trip object with updated information
final Trip trip; final Trip trip;
/// Creates a TripUpdateRequested event.
///
/// Args:
/// [trip]: The updated trip object (must have valid ID)
const TripUpdateRequested({required this.trip}); const TripUpdateRequested({required this.trip});
@override @override
List<Object?> get props => [trip]; List<Object?> get props => [trip];
} }
/// Event to reset the trip state and clean up resources.
///
/// This event resets the TripBloc to its initial state, cancels any active
/// stream subscriptions, and clears the current user context. This is typically
/// used during user logout or when switching between different user contexts.
class ResetTrips extends TripEvent { class ResetTrips extends TripEvent {
/// Creates a ResetTrips event.
const ResetTrips(); const ResetTrips();
} }
/// Event to request deletion of a trip.
///
/// This event permanently deletes a trip and all associated data including
/// groups, expenses, and messages. This is a destructive operation that cannot
/// be undone. The user's trip list is automatically refreshed after deletion.
///
/// Args:
/// [tripId]: The unique identifier of the trip to delete
class TripDeleteRequested extends TripEvent { class TripDeleteRequested extends TripEvent {
/// The unique identifier of the trip to delete
final String tripId; final String tripId;
/// Creates a TripDeleteRequested event.
///
/// Args:
/// [tripId]: The ID of the trip to delete
const TripDeleteRequested({required this.tripId}); const TripDeleteRequested({required this.tripId});
@override @override

View File

@@ -1,6 +1,27 @@
/// States for trip-related operations in the TripBloc.
///
/// This file defines all possible states that the TripBloc can emit in response
/// to trip-related events. The states represent different phases of trip
/// operations including loading, success, error, and data states for trip management.
///
/// State Categories:
/// - **Initial State**: TripInitial
/// - **Loading States**: TripLoading
/// - **Success States**: TripLoaded, TripCreated, TripOperationSuccess
/// - **Error States**: TripError
///
/// The states support complete trip lifecycle management with proper feedback
/// for create, read, update, and delete operations.
///
/// All states extend [TripState] and implement [Equatable] for proper
/// equality comparison and state change detection in the BLoC pattern.
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../models/trip.dart'; import '../../models/trip.dart';
/// Base class for all trip-related states.
///
/// All trip states must extend this class and implement the [props] getter
/// for proper equality comparison in the BLoC pattern.
abstract class TripState extends Equatable { abstract class TripState extends Equatable {
const TripState(); const TripState();
@@ -8,44 +29,111 @@ abstract class TripState extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// The initial state of the TripBloc before any operations are performed.
///
/// This is the default state when the bloc is first created and represents
/// a clean slate with no trip data loaded or operations in progress.
class TripInitial extends TripState {} class TripInitial extends TripState {}
/// State indicating that a trip operation is currently in progress.
///
/// This state is emitted when the bloc is performing operations like
/// loading trips, creating trips, updating trip information, or deleting trips.
/// UI components can use this state to show loading indicators.
class TripLoading extends TripState {} class TripLoading extends TripState {}
/// State containing a list of successfully loaded trips.
///
/// This state is emitted when trips have been successfully retrieved
/// from the repository, either through initial loading or real-time updates.
/// The UI can use this data to display the list of available trips for the user.
///
/// Properties:
/// [trips]: List of Trip objects that were loaded
class TripLoaded extends TripState { class TripLoaded extends TripState {
/// The list of loaded trips
final List<Trip> trips; final List<Trip> trips;
/// Creates a TripLoaded state.
///
/// Args:
/// [trips]: The list of trips to include in this state
const TripLoaded(this.trips); const TripLoaded(this.trips);
@override @override
List<Object?> get props => [trips]; List<Object?> get props => [trips];
} }
/// State indicating successful trip creation.
///
/// This state is emitted when a new trip has been successfully created.
/// It contains the ID of the newly created trip and a success message
/// that can be displayed to the user. This state is typically followed
/// by automatic reloading of the user's trip list.
///
/// Properties:
/// [tripId]: The unique identifier of the newly created trip
/// [message]: A success message to display to the user
class TripCreated extends TripState { class TripCreated extends TripState {
/// The unique identifier of the newly created trip
final String tripId; final String tripId;
/// Success message for the user
final String message; final String message;
/// Creates a TripCreated state.
///
/// Args:
/// [tripId]: The ID of the newly created trip
/// [message]: Optional success message (defaults to "Trip created successfully")
const TripCreated({ const TripCreated({
required this.tripId, required this.tripId,
this.message = 'Voyage créé avec succès', this.message = 'Trip created successfully',
}); });
@override @override
List<Object?> get props => [tripId, message]; List<Object?> get props => [tripId, message];
} }
/// State indicating successful completion of a trip operation.
///
/// This state is emitted when operations like updating or deleting trips
/// have completed successfully. It contains a message that can be displayed
/// to inform the user of the successful operation. This state is typically
/// followed by automatic reloading of the user's trip list.
///
/// Properties:
/// [message]: A success message describing the completed operation
class TripOperationSuccess extends TripState { class TripOperationSuccess extends TripState {
/// The success message to display to the user
final String message; final String message;
/// Creates a TripOperationSuccess state.
///
/// Args:
/// [message]: The success message to display
const TripOperationSuccess(this.message); const TripOperationSuccess(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
/// State indicating that a trip operation has failed.
///
/// This state is emitted when any trip operation encounters an error.
/// It contains an error message that can be displayed to the user to
/// explain what went wrong during the operation.
///
/// Properties:
/// [message]: An error message describing what went wrong
class TripError extends TripState { class TripError extends TripState {
/// The error message to display to the user
final String message; final String message;
/// Creates a TripError state.
///
/// Args:
/// [message]: The error message to display
const TripError(this.message); const TripError(this.message);
@override @override

View File

@@ -4,10 +4,21 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'user_event.dart' as event; import 'user_event.dart' as event;
import 'user_state.dart' as state; import 'user_state.dart' as state;
/// BLoC for managing user data and operations.
///
/// This BLoC handles user-related operations including loading user data,
/// updating user information, and managing user state throughout the application.
/// It coordinates with Firebase Auth and Firestore to manage user data persistence.
class UserBloc extends Bloc<event.UserEvent, state.UserState> { class UserBloc extends Bloc<event.UserEvent, state.UserState> {
/// Firebase Auth instance for authentication operations.
final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseAuth _auth = FirebaseAuth.instance;
/// Firestore instance for user data operations.
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Creates a new [UserBloc] with initial state.
///
/// Registers event handlers for all user-related events.
UserBloc() : super(state.UserInitial()) { UserBloc() : super(state.UserInitial()) {
on<event.UserInitialized>(_onUserInitialized); on<event.UserInitialized>(_onUserInitialized);
on<event.LoadUser>(_onLoadUser); on<event.LoadUser>(_onLoadUser);
@@ -15,6 +26,11 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
on<event.UserLoggedOut>(_onUserLoggedOut); on<event.UserLoggedOut>(_onUserLoggedOut);
} }
/// Handles [UserInitialized] events.
///
/// Initializes the current authenticated user's data by fetching it from Firestore.
/// If the user doesn't exist in Firestore, creates a default user document.
/// This is typically called when the app starts or after successful authentication.
Future<void> _onUserInitialized( Future<void> _onUserInitialized(
event.UserInitialized event, event.UserInitialized event,
Emitter<state.UserState> emit, Emitter<state.UserState> emit,
@@ -25,18 +41,18 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
final currentUser = _auth.currentUser; final currentUser = _auth.currentUser;
if (currentUser == null) { if (currentUser == null) {
emit(state.UserError('Aucun utilisateur connecté')); emit(state.UserError('No user currently authenticated'));
return; return;
} }
// Récupérer les données utilisateur depuis Firestore // Fetch user data from Firestore
final userDoc = await _firestore final userDoc = await _firestore
.collection('users') .collection('users')
.doc(currentUser.uid) .doc(currentUser.uid)
.get(); .get();
if (!userDoc.exists) { if (!userDoc.exists) {
// Créer un utilisateur par défaut si non existant // Create a default user if it doesn't exist
final defaultUser = state.UserModel( final defaultUser = state.UserModel(
id: currentUser.uid, id: currentUser.uid,
email: currentUser.email ?? '', email: currentUser.email ?? '',
@@ -57,10 +73,14 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
emit(state.UserLoaded(user)); emit(state.UserLoaded(user));
} }
} catch (e) { } catch (e) {
emit(state.UserError('Erreur lors du chargement de l\'utilisateur: $e')); emit(state.UserError('Error loading user: $e'));
} }
} }
/// Handles [LoadUser] events.
///
/// Loads a specific user's data from Firestore by their user ID.
/// This is useful when you need to display information about other users.
Future<void> _onLoadUser( Future<void> _onLoadUser(
event.LoadUser event, event.LoadUser event,
Emitter<state.UserState> emit, Emitter<state.UserState> emit,
@@ -80,13 +100,18 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
}); });
emit(state.UserLoaded(user)); emit(state.UserLoaded(user));
} else { } else {
emit(state.UserError('Utilisateur non trouvé')); emit(state.UserError('User not found'));
} }
} catch (e) { } catch (e) {
emit(state.UserError('Erreur lors du chargement: $e')); emit(state.UserError('Error loading user: $e'));
} }
} }
/// Handles [UserUpdated] events.
///
/// Updates the current user's data in Firestore with the provided information.
/// Only updates the fields specified in the userData map, allowing for partial updates.
/// After successful update, reloads the user data to reflect changes.
Future<void> _onUserUpdated( Future<void> _onUserUpdated(
event.UserUpdated event, event.UserUpdated event,
Emitter<state.UserState> emit, Emitter<state.UserState> emit,
@@ -112,11 +137,15 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
emit(state.UserLoaded(updatedUser)); emit(state.UserLoaded(updatedUser));
} catch (e) { } catch (e) {
emit(state.UserError('Erreur lors de la mise à jour: $e')); emit(state.UserError('Error updating user: $e'));
} }
} }
} }
/// Handles [UserLoggedOut] events.
///
/// Resets the user bloc state to initial when the user logs out.
/// This clears any cached user data from the application state.
Future<void> _onUserLoggedOut( Future<void> _onUserLoggedOut(
event.UserLoggedOut event, event.UserLoggedOut event,
Emitter<state.UserState> emit, Emitter<state.UserState> emit,

View File

@@ -1,24 +1,47 @@
/// Abstract base class for all user-related events.
///
/// All user events in the application should inherit from this class.
/// This provides a common interface for user-related operations.
abstract class UserEvent {} abstract class UserEvent {}
/// Event to initialize the current user's data.
///
/// This event is typically dispatched when the app starts or when
/// a user signs in to load their profile information from Firestore.
class UserInitialized extends UserEvent {} class UserInitialized extends UserEvent {}
class UserLoaded extends UserEvent { /// Event to load a specific user's data by their ID.
///
/// This event is used to fetch user information from Firestore
/// when you need to display or work with a specific user's data.
class LoadUser extends UserEvent {
/// The ID of the user to load.
final String userId; final String userId;
UserLoaded(this.userId); /// Creates a [LoadUser] event with the specified [userId].
LoadUser(this.userId);
} }
/// Event to update the current user's data.
///
/// This event is dispatched when the user modifies their profile
/// information and the changes need to be saved to Firestore.
class UserUpdated extends UserEvent { class UserUpdated extends UserEvent {
/// Map containing the user data fields to update.
///
/// Only the fields present in this map will be updated in Firestore.
/// This allows for partial updates of user information.
final Map<String, dynamic> userData; final Map<String, dynamic> userData;
/// Creates a [UserUpdated] event with the specified [userData].
UserUpdated(this.userData); UserUpdated(this.userData);
} }
/// Event to handle user logout.
///
/// This event is dispatched when the user signs out of the application,
/// clearing their data from the user bloc state.
class UserLoggedOut extends UserEvent { class UserLoggedOut extends UserEvent {
/// Creates a [UserLoggedOut] event.
UserLoggedOut(); UserLoggedOut();
} }
class LoadUser extends UserEvent {
final String userId;
LoadUser(this.userId);
}

View File

@@ -1,39 +1,81 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Abstract base class for all user-related states.
///
/// This class extends [Equatable] to enable value equality for state comparison.
/// All user states in the application should inherit from this class.
abstract class UserState extends Equatable { abstract class UserState extends Equatable {
@override @override
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Initial state of the user bloc.
///
/// This state represents the initial state before any user operations
/// have been performed or when the user has logged out.
class UserInitial extends UserState {} class UserInitial extends UserState {}
/// State indicating that a user operation is in progress.
///
/// This state is used to show loading indicators during user data
/// operations like loading, updating, or initializing user information.
class UserLoading extends UserState {} class UserLoading extends UserState {}
/// State indicating that user data has been successfully loaded.
///
/// This state contains the loaded user information and is used
/// throughout the app to access current user data.
class UserLoaded extends UserState { class UserLoaded extends UserState {
/// The loaded user data.
final UserModel user; final UserModel user;
/// Creates a [UserLoaded] state with the given [user] data.
UserLoaded(this.user); UserLoaded(this.user);
@override @override
List<Object?> get props => [user]; List<Object?> get props => [user];
} }
/// State indicating that a user operation has failed.
///
/// This state contains an error message that can be displayed to the user
/// when user operations fail.
class UserError extends UserState { class UserError extends UserState {
/// The error message describing what went wrong.
final String message; final String message;
/// Creates a [UserError] state with the given error [message].
UserError(this.message); UserError(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }
// Modèle utilisateur simple /// Simple user model for representing user data in the application.
///
/// This model contains basic user information and provides methods for
/// serialization/deserialization with Firestore operations.
/// Simple user model for representing user data in the application.
///
/// This model contains basic user information and provides methods for
/// serialization/deserialization with Firestore operations.
class UserModel { class UserModel {
/// Unique identifier for the user (Firebase UID).
final String id; final String id;
/// User's email address.
final String email; final String email;
/// User's first name.
final String prenom; final String prenom;
/// User's last name (optional).
final String? nom; final String? nom;
/// Creates a new [UserModel] instance.
///
/// [id], [email], and [prenom] are required fields.
/// [nom] is optional and can be null.
UserModel({ UserModel({
required this.id, required this.id,
required this.email, required this.email,
@@ -41,6 +83,10 @@ class UserModel {
this.nom, this.nom,
}); });
/// Creates a [UserModel] instance from a JSON map.
///
/// Handles null values gracefully by providing default values.
/// [prenom] defaults to 'Voyageur' (Traveler) if not provided.
factory UserModel.fromJson(Map<String, dynamic> json) { factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel( return UserModel(
id: json['id'] ?? '', id: json['id'] ?? '',
@@ -50,6 +96,9 @@ class UserModel {
); );
} }
/// Converts the [UserModel] instance to a JSON map.
///
/// Useful for storing user data in Firestore or other JSON-based operations.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,

View File

@@ -1,3 +1,23 @@
/// A widget that displays the account content page for the travel expense app.
///
/// This component manages the display of user accounts (groups) and provides
/// navigation to group expense management. It handles loading account data,
/// error states, and navigation to the group expenses page.
///
/// Features:
/// - **Account Loading**: Automatically loads accounts for the current user
/// - **Error Handling**: Displays error states with retry functionality
/// - **Navigation**: Navigates to group expense management pages
/// - **Real-time Updates**: Uses BLoC pattern for reactive state management
/// - **Pull to Refresh**: Allows users to refresh account data
///
/// Dependencies:
/// - [UserBloc]: For current user state management
/// - [AccountBloc]: For account data management
/// - [GroupRepository]: For group data operations
///
/// The component automatically loads account data when initialized and
/// provides a clean interface for managing group-based expenses.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:travel_mate/blocs/user/user_bloc.dart'; import 'package:travel_mate/blocs/user/user_bloc.dart';
import '../../models/account.dart'; import '../../models/account.dart';
@@ -10,6 +30,7 @@ import '../../blocs/user/user_state.dart' as user_state;
import '../../repositories/group_repository.dart'; // Ajouter cet import import '../../repositories/group_repository.dart'; // Ajouter cet import
import 'group_expenses_page.dart'; // Ajouter cet import import 'group_expenses_page.dart'; // Ajouter cet import
/// Widget that displays the account content page with account management functionality.
class AccountContent extends StatefulWidget { class AccountContent extends StatefulWidget {
const AccountContent({super.key}); const AccountContent({super.key});
@@ -17,19 +38,25 @@ class AccountContent extends StatefulWidget {
State<AccountContent> createState() => _AccountContentState(); State<AccountContent> createState() => _AccountContentState();
} }
/// State class for AccountContent that manages account loading and navigation.
class _AccountContentState extends State<AccountContent> { class _AccountContentState extends State<AccountContent> {
/// Repository for group data operations used for navigation
final _groupRepository = GroupRepository(); // Ajouter cette ligne final _groupRepository = GroupRepository(); // Ajouter cette ligne
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Charger immédiatement sans attendre le prochain frame // Load immediately without waiting for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_loadInitialData(); _loadInitialData();
}); });
} }
/// Loads the initial account data for the current user.
///
/// Retrieves the current user from UserBloc and loads their accounts
/// using the AccountBloc. Handles errors gracefully with error display.
void _loadInitialData() { void _loadInitialData() {
try { try {
final userState = context.read<UserBloc>().state; final userState = context.read<UserBloc>().state;
@@ -38,20 +65,27 @@ class _AccountContentState extends State<AccountContent> {
final userId = userState.user.id; final userId = userState.user.id;
context.read<AccountBloc>().add(LoadAccountsByUserId(userId)); context.read<AccountBloc>().add(LoadAccountsByUserId(userId));
} else { } else {
throw Exception('Utilisateur non connecté'); throw Exception('User not connected');
} }
} catch (e) { } catch (e) {
ErrorContent( ErrorContent(
message: 'Erreur lors du chargement des comptes: $e', message: 'Error loading accounts: $e',
onRetry: () {}, onRetry: () {},
); );
} }
} }
// Nouvelle méthode pour naviguer vers la page des dépenses de groupe /// Navigates to the group expenses page for a specific account.
///
/// Retrieves the group associated with the account and navigates to
/// the group expenses management page. Shows error messages if the
/// group cannot be found or if navigation fails.
///
/// Args:
/// [account]: The account to navigate to for expense management
Future<void> _navigateToGroupExpenses(Account account) async { Future<void> _navigateToGroupExpenses(Account account) async {
try { try {
// Récupérer le groupe associé au compte // Retrieve the group associated with the account
final group = await _groupRepository.getGroupByTripId(account.tripId); final group = await _groupRepository.getGroupByTripId(account.tripId);
if (group != null && mounted) { if (group != null && mounted) {
@@ -68,7 +102,7 @@ class _AccountContentState extends State<AccountContent> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Groupe non trouvé pour ce compte'), content: Text('Group not found for this account'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -78,7 +112,7 @@ class _AccountContentState extends State<AccountContent> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Erreur lors du chargement du groupe: $e'), content: Text('Error loading group: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -86,6 +120,15 @@ class _AccountContentState extends State<AccountContent> {
} }
} }
/// Builds the main widget for the account content page.
///
/// Creates a responsive UI that handles different user and account states:
/// - Shows loading indicator for user authentication
/// - Displays error content for user errors
/// - Builds account content based on account loading states
///
/// Returns:
/// Widget representing the complete account page UI
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<UserBloc, user_state.UserState>( return BlocBuilder<UserBloc, user_state.UserState>(
@@ -98,14 +141,14 @@ class _AccountContentState extends State<AccountContent> {
if (userState is user_state.UserError) { if (userState is user_state.UserError) {
return ErrorContent( return ErrorContent(
message: 'Erreur utilisateur: ${userState.message}', message: 'User error: ${userState.message}',
onRetry: () {}, onRetry: () {},
); );
} }
if (userState is! user_state.UserLoaded) { if (userState is! user_state.UserLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: Text('Utilisateur non connecté')), body: Center(child: Text('User not connected')),
); );
} }
final user = userState.user; final user = userState.user;
@@ -114,7 +157,7 @@ class _AccountContentState extends State<AccountContent> {
listener: (context, accountState) { listener: (context, accountState) {
if (accountState is AccountError) { if (accountState is AccountError) {
ErrorContent( ErrorContent(
message: 'Erreur de chargement des comptes: ${accountState.message}', message: 'Account loading error: ${accountState.message}',
onRetry: () { onRetry: () {
context.read<AccountBloc>().add(LoadAccountsByUserId(user.id)); context.read<AccountBloc>().add(LoadAccountsByUserId(user.id));
}, },
@@ -131,6 +174,19 @@ class _AccountContentState extends State<AccountContent> {
); );
} }
/// Builds the main content based on the current account state.
///
/// Handles different account loading states and renders appropriate UI:
/// - Loading: Shows circular progress indicator with loading message
/// - Error: Displays error content with retry functionality
/// - Loaded: Renders the accounts list or empty state message
///
/// Args:
/// [accountState]: Current state of account loading
/// [userId]: ID of the current user for reload operations
///
/// Returns:
/// Widget representing the account content UI
Widget _buildContent(AccountState accountState, String userId) { Widget _buildContent(AccountState accountState, String userId) {
if (accountState is AccountLoading) { if (accountState is AccountLoading) {
return const Center( return const Center(
@@ -139,7 +195,7 @@ class _AccountContentState extends State<AccountContent> {
children: [ children: [
CircularProgressIndicator(), CircularProgressIndicator(),
SizedBox(height: 16), SizedBox(height: 16),
Text('Chargement des comptes...'), Text('Loading accounts...'),
], ],
), ),
); );
@@ -147,7 +203,7 @@ class _AccountContentState extends State<AccountContent> {
if (accountState is AccountError) { if (accountState is AccountError) {
return ErrorContent( return ErrorContent(
message: 'Erreur de chargement des comptes...', message: 'Account loading error...',
onRetry: () { onRetry: () {
context.read<AccountBloc>().add(LoadAccountsByUserId(userId)); context.read<AccountBloc>().add(LoadAccountsByUserId(userId));
}, },
@@ -165,19 +221,27 @@ class _AccountContentState extends State<AccountContent> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('État inconnu'), const Text('Unknown state'),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
context.read<AccountBloc>().add(LoadAccountsByUserId(userId)); context.read<AccountBloc>().add(LoadAccountsByUserId(userId));
}, },
child: const Text('Charger les comptes'), child: const Text('Load accounts'),
), ),
], ],
), ),
); );
} }
/// Builds the empty state widget when no accounts are found.
///
/// Displays a user-friendly message explaining that accounts are
/// automatically created when trips are created. Shows an icon
/// and informative text to guide the user.
///
/// Returns:
/// Widget representing the empty accounts state
Widget _buildEmptyState() { Widget _buildEmptyState() {
return Center( return Center(
child: Padding( child: Padding(
@@ -188,12 +252,12 @@ class _AccountContentState extends State<AccountContent> {
const Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey), const Icon(Icons.account_balance_wallet, size: 80, color: Colors.grey),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'Aucun compte trouvé', 'No accounts found',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'Les comptes sont créés automatiquement lorsque vous créez un voyage', 'Accounts are automatically created when you create a trip',
style: TextStyle(fontSize: 14, color: Colors.grey), style: TextStyle(fontSize: 14, color: Colors.grey),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -203,6 +267,18 @@ class _AccountContentState extends State<AccountContent> {
); );
} }
/// Builds a scrollable list of user accounts with pull-to-refresh functionality.
///
/// Creates a RefreshIndicator-wrapped ListView that displays all user accounts
/// in card format. Includes a header with title and description, and renders
/// each account using the _buildSimpleAccountCard method.
///
/// Args:
/// [accounts]: List of accounts to display
/// [userId]: Current user ID for refresh operations
///
/// Returns:
/// Widget containing the accounts list with pull-to-refresh capability
Widget _buildAccountsList(List<Account> accounts, String userId) { Widget _buildAccountsList(List<Account> accounts, String userId) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -213,12 +289,12 @@ class _AccountContentState extends State<AccountContent> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
const Text( const Text(
'Mes comptes', 'My accounts',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Gérez vos comptes de voyage', 'Manage your travel accounts',
style: TextStyle(fontSize: 14, color: Colors.grey[600]), style: TextStyle(fontSize: 14, color: Colors.grey[600]),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -234,12 +310,25 @@ class _AccountContentState extends State<AccountContent> {
); );
} }
/// Builds an individual account card with account information.
///
/// Creates a Material Design card displaying account details including:
/// - Account name with color-coded avatar
/// - Member count and member names (up to 2 displayed)
/// - Navigation capability to group expenses
/// - Error handling for card rendering issues
///
/// Args:
/// [account]: Account object containing account details
///
/// Returns:
/// Widget representing a single account card
Widget _buildSimpleAccountCard(Account account) { Widget _buildSimpleAccountCard(Account account) {
try { try {
final colors = [Colors.blue, Colors.purple, Colors.green, Colors.orange]; final colors = [Colors.blue, Colors.purple, Colors.green, Colors.orange];
final color = colors[account.name.hashCode.abs() % colors.length]; final color = colors[account.name.hashCode.abs() % colors.length];
String memberInfo = '${account.members.length} membre${account.members.length > 1 ? 's' : ''}'; String memberInfo = '${account.members.length} member${account.members.length > 1 ? 's' : ''}';
if(account.members.isNotEmpty){ if(account.members.isNotEmpty){
final names = account.members final names = account.members
@@ -262,7 +351,7 @@ class _AccountContentState extends State<AccountContent> {
), ),
subtitle: Text(memberInfo), subtitle: Text(memberInfo),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateToGroupExpenses(account), // Modifier cette ligne onTap: () => _navigateToGroupExpenses(account), // Navigate to group expenses
), ),
); );
} catch (e) { } catch (e) {
@@ -270,7 +359,7 @@ class _AccountContentState extends State<AccountContent> {
color: Colors.red, color: Colors.red,
child: const ListTile( child: const ListTile(
leading: Icon(Icons.error, color: Colors.red), leading: Icon(Icons.error, color: Colors.red),
title: Text('Erreur d\'affichage'), title: Text('Display error'),
) )
); );
} }

View File

@@ -1,16 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// A reusable error display component.
///
/// This widget provides a consistent way to display error messages throughout
/// the application. It supports customizable titles, messages, icons, and
/// action buttons for retry and close operations.
class ErrorContent extends StatelessWidget { class ErrorContent extends StatelessWidget {
/// The error title to display
final String title; final String title;
/// The error message to display
final String message; final String message;
/// Optional callback for retry action
final VoidCallback? onRetry; final VoidCallback? onRetry;
/// Optional callback for close action
final VoidCallback? onClose; final VoidCallback? onClose;
/// Icon to display with the error
final IconData icon; final IconData icon;
/// Color of the error icon
final Color? iconColor; final Color? iconColor;
/// Creates a new [ErrorContent] widget.
///
/// [message] is required, other parameters are optional with sensible defaults.
const ErrorContent({ const ErrorContent({
super.key, super.key,
this.title = 'Une erreur est survenue', this.title = 'An error occurred',
required this.message, required this.message,
this.onRetry, this.onRetry,
this.onClose, this.onClose,

View File

@@ -8,9 +8,26 @@ import '../../blocs/message/message_state.dart';
import '../../models/group.dart'; import '../../models/group.dart';
import '../../models/message.dart'; import '../../models/message.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
/// - Message editing and deletion
/// - 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 { class ChatGroupContent extends StatefulWidget {
/// The group for which to display the chat interface
final Group group; final Group group;
/// Creates a chat group content widget.
///
/// Args:
/// [group]: The group object containing group details and ID
const ChatGroupContent({ const ChatGroupContent({
super.key, super.key,
required this.group, required this.group,
@@ -21,14 +38,19 @@ class ChatGroupContent extends StatefulWidget {
} }
class _ChatGroupContentState extends State<ChatGroupContent> { class _ChatGroupContentState extends State<ChatGroupContent> {
/// Controller for the message input field
final _messageController = TextEditingController(); final _messageController = TextEditingController();
/// Controller for managing scroll position in the message list
final _scrollController = ScrollController(); final _scrollController = ScrollController();
/// Currently selected message for editing (null if not editing)
Message? _editingMessage; Message? _editingMessage;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Charger les messages au démarrage // Load messages when the widget initializes
context.read<MessageBloc>().add(LoadMessages(widget.group.id)); context.read<MessageBloc>().add(LoadMessages(widget.group.id));
} }
@@ -39,12 +61,20 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
super.dispose(); super.dispose();
} }
/// 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) { void _sendMessage(user_state.UserModel currentUser) {
final messageText = _messageController.text.trim(); final messageText = _messageController.text.trim();
if (messageText.isEmpty) return; if (messageText.isEmpty) return;
if (_editingMessage != null) { if (_editingMessage != null) {
// Mode édition // Edit mode - update existing message
context.read<MessageBloc>().add( context.read<MessageBloc>().add(
UpdateMessage( UpdateMessage(
groupId: widget.group.id, groupId: widget.group.id,
@@ -54,7 +84,7 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
); );
_cancelEdit(); _cancelEdit();
} else { } else {
// Mode envoi // Send mode - create new message
context.read<MessageBloc>().add( context.read<MessageBloc>().add(
SendMessage( SendMessage(
groupId: widget.group.id, groupId: widget.group.id,
@@ -68,6 +98,13 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
_messageController.clear(); _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) { void _editMessage(Message message) {
setState(() { setState(() {
_editingMessage = message; _editingMessage = message;
@@ -75,6 +112,10 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}); });
} }
/// Cancels the current editing operation.
///
/// Resets the editing state and clears the input field,
/// returning to normal message sending mode.
void _cancelEdit() { void _cancelEdit() {
setState(() { setState(() {
_editingMessage = null; _editingMessage = null;
@@ -82,6 +123,13 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}); });
} }
/// 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) { void _deleteMessage(String messageId) {
context.read<MessageBloc>().add( context.read<MessageBloc>().add(
DeleteMessage( DeleteMessage(

View File

@@ -20,8 +20,29 @@ import '../../repositories/group_repository.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.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
/// - Location search with autocomplete
/// - Date selection for trip duration
/// - Budget planning and management
/// - 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 { class CreateTripContent extends StatefulWidget {
/// Optional trip to edit. If null, creates a new trip
final Trip? tripToEdit; 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({ const CreateTripContent({
super.key, super.key,
this.tripToEdit, this.tripToEdit,
@@ -32,30 +53,44 @@ class CreateTripContent extends StatefulWidget {
} }
class _CreateTripContentState extends State<CreateTripContent> { class _CreateTripContentState extends State<CreateTripContent> {
/// Service for handling and displaying errors
final _errorService = ErrorService(); final _errorService = ErrorService();
/// Form validation key
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Text controllers for form fields
final _titleController = TextEditingController(); final _titleController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
final _locationController = TextEditingController(); final _locationController = TextEditingController();
final _budgetController = TextEditingController(); final _budgetController = TextEditingController();
final _participantController = TextEditingController();
/// Services for user and group operations
final _userService = UserService(); final _userService = UserService();
final _groupRepository = GroupRepository(); final _groupRepository = GroupRepository();
/// Trip date variables
DateTime? _startDate; DateTime? _startDate;
DateTime? _endDate; DateTime? _endDate;
/// Loading and state management variables
bool _isLoading = false; bool _isLoading = false;
String? _createdTripId; String? _createdTripId;
/// Google Maps API key for location services
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? ''; static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
/// Participant management
final List<String> _participants = []; final List<String> _participants = [];
final _participantController = TextEditingController();
/// Location autocomplete functionality
List<PlaceSuggestion> _placeSuggestions = []; List<PlaceSuggestion> _placeSuggestions = [];
bool _isLoadingSuggestions = false; bool _isLoadingSuggestions = false;
OverlayEntry? _suggestionsOverlay; OverlayEntry? _suggestionsOverlay;
final LayerLink _layerLink = LayerLink(); final LayerLink _layerLink = LayerLink();
/// Determines if the widget is in editing mode
bool get isEditing => widget.tripToEdit != null; bool get isEditing => widget.tripToEdit != null;
@override @override

View File

@@ -9,7 +9,20 @@ import '../../blocs/trip/trip_state.dart';
import '../../blocs/trip/trip_event.dart'; import '../../blocs/trip/trip_event.dart';
import '../../models/trip.dart'; import '../../models/trip.dart';
/// Home content widget for the main application dashboard.
///
/// This widget serves as the primary content area of the home screen,
/// displaying user trips and providing navigation to trip management
/// features. Key functionality includes:
/// - Loading and displaying user trips
/// - Creating new trips
/// - Viewing trip details
/// - Managing trip state with proper user authentication
///
/// The widget maintains state persistence using AutomaticKeepAliveClientMixin
/// to preserve content when switching between tabs.
class HomeContent extends StatefulWidget { class HomeContent extends StatefulWidget {
/// Creates a home content widget.
const HomeContent({super.key}); const HomeContent({super.key});
@override @override
@@ -17,20 +30,27 @@ class HomeContent extends StatefulWidget {
} }
class _HomeContentState extends State<HomeContent> with AutomaticKeepAliveClientMixin { class _HomeContentState extends State<HomeContent> with AutomaticKeepAliveClientMixin {
/// Preserves widget state when switching between tabs
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
/// Flag to prevent duplicate trip loading operations
bool _hasLoadedTrips = false; bool _hasLoadedTrips = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// MODIFIÉ : Utiliser addPostFrameCallback pour attendre que le widget tree soit prêt // Use addPostFrameCallback to wait for the widget tree to be ready
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_loadTripsIfUserLoaded(); _loadTripsIfUserLoaded();
}); });
} }
/// Loads trips if a user is currently loaded and trips haven't been loaded yet.
///
/// Checks the current user state and initiates trip loading if the user is
/// authenticated and trips haven't been loaded previously. This prevents
/// duplicate loading operations.
void _loadTripsIfUserLoaded() { void _loadTripsIfUserLoaded() {
if (!_hasLoadedTrips && mounted) { if (!_hasLoadedTrips && mounted) {
final userState = context.read<UserBloc>().state; final userState = context.read<UserBloc>().state;

View File

@@ -30,6 +30,10 @@ import 'pages/home.dart';
import 'pages/signup.dart'; import 'pages/signup.dart';
import 'pages/resetpswd.dart'; import 'pages/resetpswd.dart';
/// Entry point of the Travel Mate application.
///
/// This function initializes Flutter widgets, loads environment variables,
/// initializes Firebase, and starts the application.
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");
@@ -38,45 +42,63 @@ void main() async {
runApp(const MyApp()); runApp(const MyApp());
} }
/// The root widget of the Travel Mate application.
///
/// This widget sets up the BLoC providers, repositories, and MaterialApp
/// configuration. It manages the application's theme, routing, and global state.
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
/// Creates the main application widget.
const MyApp({super.key}); const MyApp({super.key});
/// Builds the widget tree for the application.
///
/// Sets up repository providers for dependency injection, BLoC providers
/// for state management, and configures the MaterialApp with themes and routing.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiRepositoryProvider( return MultiRepositoryProvider(
providers: [ providers: [
// Authentication repository for handling user authentication
RepositoryProvider<AuthRepository>( RepositoryProvider<AuthRepository>(
create: (context) => AuthRepository(), create: (context) => AuthRepository(),
), ),
// User repository for managing user data
RepositoryProvider<UserRepository>( RepositoryProvider<UserRepository>(
create: (context) => UserRepository(), create: (context) => UserRepository(),
), ),
// Trip repository for managing travel trips
RepositoryProvider<TripRepository>( RepositoryProvider<TripRepository>(
create: (context) => TripRepository(), create: (context) => TripRepository(),
), ),
// Group repository for managing travel groups
RepositoryProvider<GroupRepository>( RepositoryProvider<GroupRepository>(
create: (context) => GroupRepository(), create: (context) => GroupRepository(),
), ),
// Message repository for handling in-app messaging
RepositoryProvider<MessageRepository>( RepositoryProvider<MessageRepository>(
create: (context) => MessageRepository(), create: (context) => MessageRepository(),
), ),
// Account repository for managing user account settings
RepositoryProvider<AccountRepository>( RepositoryProvider<AccountRepository>(
create: (context) => AccountRepository(), create: (context) => AccountRepository(),
), ),
// Expense repository for managing trip expenses
RepositoryProvider<ExpenseRepository>( RepositoryProvider<ExpenseRepository>(
create: (context) => ExpenseRepository(), create: (context) => ExpenseRepository(),
), ),
// Provide service instances so BLoCs can read them with context.read<T>() // Expense service for business logic related to expenses
RepositoryProvider<ExpenseService>( RepositoryProvider<ExpenseService>(
create: (context) => ExpenseService( create: (context) => ExpenseService(
expenseRepository: context.read<ExpenseRepository>(), expenseRepository: context.read<ExpenseRepository>(),
), ),
), ),
// Balance repository for calculating expense balances
RepositoryProvider<BalanceRepository>( RepositoryProvider<BalanceRepository>(
create: (context) => BalanceRepository( create: (context) => BalanceRepository(
expenseRepository: context.read<ExpenseRepository>(), expenseRepository: context.read<ExpenseRepository>(),
), ),
), ),
// Balance service for business logic related to balances
RepositoryProvider<BalanceService>( RepositoryProvider<BalanceService>(
create: (context) => BalanceService( create: (context) => BalanceService(
balanceRepository: context.read<BalanceRepository>(), balanceRepository: context.read<BalanceRepository>(),
@@ -87,38 +109,46 @@ class MyApp extends StatelessWidget {
], ],
child: MultiBlocProvider( child: MultiBlocProvider(
providers: [ providers: [
// Theme BLoC for managing app theme preferences
BlocProvider<ThemeBloc>( BlocProvider<ThemeBloc>(
create: (context) => ThemeBloc()..add(ThemeLoadRequested()), create: (context) => ThemeBloc()..add(ThemeLoadRequested()),
), ),
// Authentication BLoC for managing user authentication state
BlocProvider<AuthBloc>( BlocProvider<AuthBloc>(
create: (context) => create: (context) =>
AuthBloc(authRepository: context.read<AuthRepository>()) AuthBloc(authRepository: context.read<AuthRepository>())
..add(AuthCheckRequested()), ..add(AuthCheckRequested()),
), ),
// Group BLoC for managing travel groups
BlocProvider( BlocProvider(
create: (context) => GroupBloc(context.read<GroupRepository>()), create: (context) => GroupBloc(context.read<GroupRepository>()),
), ),
// Trip BLoC for managing travel trips
BlocProvider( BlocProvider(
create: (context) => create: (context) =>
TripBloc(context.read<TripRepository>()), TripBloc(context.read<TripRepository>()),
), ),
// User BLoC for managing user data
BlocProvider(create: (context) => UserBloc()), BlocProvider(create: (context) => UserBloc()),
// Message BLoC for managing in-app messaging
BlocProvider( BlocProvider(
create: (context) => MessageBloc(), create: (context) => MessageBloc(),
), ),
// Account BLoC for managing user account settings
BlocProvider( BlocProvider(
create: (context) => AccountBloc( create: (context) => AccountBloc(
context.read<AccountRepository>(), context.read<AccountRepository>(),
), ),
), ),
// Nouveaux blocs // Expense BLoC for managing trip expenses
BlocProvider<ExpenseBloc>( BlocProvider<ExpenseBloc>(
create: (context) => ExpenseBloc( create: (context) => ExpenseBloc(
expenseRepository: context.read<ExpenseRepository>(), expenseRepository: context.read<ExpenseRepository>(),
expenseService: context.read<ExpenseService>(), expenseService: context.read<ExpenseService>(),
), ),
), ),
// Balance BLoC for managing expense balances and calculations
BlocProvider<BalanceBloc>( BlocProvider<BalanceBloc>(
create: (context) => BalanceBloc( create: (context) => BalanceBloc(
balanceRepository: context.read<BalanceRepository>(), balanceRepository: context.read<BalanceRepository>(),
@@ -134,6 +164,7 @@ class MyApp extends StatelessWidget {
title: 'Travel Mate', title: 'Travel Mate',
navigatorKey: ErrorService.navigatorKey, navigatorKey: ErrorService.navigatorKey,
themeMode: themeState.themeMode, themeMode: themeState.themeMode,
// Light theme configuration
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 180, 180, 180), seedColor: const Color.fromARGB(255, 180, 180, 180),
@@ -141,6 +172,7 @@ class MyApp extends StatelessWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
// Dark theme configuration
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 43, 43, 43), seedColor: const Color.fromARGB(255, 43, 43, 43),
@@ -148,7 +180,9 @@ class MyApp extends StatelessWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
// Default page when app starts
home: const LoginPage(), home: const LoginPage(),
// Named routes for navigation
routes: { routes: {
'/login': (context) => const LoginPage(), '/login': (context) => const LoginPage(),
'/signup': (context) => const SignUpPage(), '/signup': (context) => const SignUpPage(),

View File

@@ -3,47 +3,109 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'expense_split.dart'; import 'expense_split.dart';
/// Enumeration of supported currencies for expenses.
///
/// Each currency includes both a display symbol and standard currency code.
enum ExpenseCurrency { enum ExpenseCurrency {
/// Euro currency
eur('', 'EUR'), eur('', 'EUR'),
/// US Dollar currency
usd('\$', 'USD'), usd('\$', 'USD'),
/// British Pound currency
gbp('£', 'GBP'); gbp('£', 'GBP');
const ExpenseCurrency(this.symbol, this.code); const ExpenseCurrency(this.symbol, this.code);
/// Currency symbol for display (e.g., €, $, £)
final String symbol; final String symbol;
/// Standard currency code (e.g., EUR, USD, GBP)
final String code; final String code;
} }
/// Enumeration of expense categories with display names and icons.
///
/// Provides predefined categories for organizing travel expenses.
enum ExpenseCategory { enum ExpenseCategory {
/// Restaurant and food expenses
restaurant('Restaurant', Icons.restaurant), restaurant('Restaurant', Icons.restaurant),
/// Transportation expenses
transport('Transport', Icons.directions_car), transport('Transport', Icons.directions_car),
accommodation('Hébergement', Icons.hotel), /// Accommodation and lodging expenses
entertainment('Loisirs', Icons.local_activity), accommodation('Accommodation', Icons.hotel),
/// Entertainment and activity expenses
entertainment('Entertainment', Icons.local_activity),
/// Shopping expenses
shopping('Shopping', Icons.shopping_bag), shopping('Shopping', Icons.shopping_bag),
other('Autre', Icons.category); /// Other miscellaneous expenses
other('Other', Icons.category);
const ExpenseCategory(this.displayName, this.icon); const ExpenseCategory(this.displayName, this.icon);
/// Human-readable display name for the category
final String displayName; final String displayName;
/// Icon representing the category
final IconData icon; 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 { class Expense extends Equatable {
/// Unique identifier for the expense
final String id; final String id;
/// ID of the group this expense belongs to
final String groupId; final String groupId;
/// Description of the expense
final String description; final String description;
/// Amount of the expense in the original currency
final double amount; final double amount;
/// Currency of the expense
final ExpenseCurrency currency; final ExpenseCurrency currency;
final double amountInEur; // Montant converti en EUR
/// Amount converted to EUR for standardized calculations
final double amountInEur;
/// Category of the expense
final ExpenseCategory category; final ExpenseCategory category;
/// ID of the user who paid for this expense
final String paidById; final String paidById;
/// Name of the user who paid for this expense
final String paidByName; final String paidByName;
/// Date when the expense occurred
final DateTime date; final DateTime date;
/// Timestamp when the expense was created
final DateTime createdAt; final DateTime createdAt;
/// Timestamp when the expense was last edited (null if never edited)
final DateTime? editedAt; final DateTime? editedAt;
/// Whether this expense has been edited after creation
final bool isEdited; final bool isEdited;
/// Whether this expense has been archived
final bool isArchived; final bool isArchived;
/// URL to the receipt image (optional)
final String? receiptUrl; final String? receiptUrl;
/// List of expense splits showing how the cost is divided
final List<ExpenseSplit> splits; final List<ExpenseSplit> splits;
/// Creates a new [Expense] instance.
///
/// All parameters except [editedAt] and [receiptUrl] are required.
const Expense({ const Expense({
required this.id, required this.id,
required this.groupId, required this.groupId,

View File

@@ -1,14 +1,37 @@
import 'group_member.dart'; import 'group_member.dart';
/// Model representing a travel group.
///
/// A group is a collection of travelers who are part of a specific trip.
/// It contains information about group members, creation details, and
/// provides methods for serialization with Firestore.
class Group { class Group {
/// Unique identifier for the group
final String id; final String id;
/// Display name of the group
final String name; final String name;
/// ID of the trip this group belongs to
final String tripId; final String tripId;
/// ID of the user who created this group
final String createdBy; final String createdBy;
/// Timestamp when the group was created
final DateTime createdAt; final DateTime createdAt;
/// Timestamp when the group was last updated
final DateTime updatedAt; final DateTime updatedAt;
/// List of members in this group
final List<GroupMember> members; final List<GroupMember> members;
/// Creates a new [Group] instance.
///
/// [id], [name], [tripId], and [createdBy] are required.
/// [createdAt] and [updatedAt] default to current time if not provided.
/// [members] defaults to empty list if not provided.
Group({ Group({
required this.id, required this.id,
required this.name, required this.name,
@@ -21,6 +44,12 @@ class Group {
updatedAt = updatedAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(),
members = members ?? []; members = members ?? [];
/// Creates a [Group] instance from a Firestore document map.
///
/// [map] - The document data from Firestore
/// [id] - The document ID from Firestore
///
/// Returns a new [Group] instance with data from the map.
factory Group.fromMap(Map<String, dynamic> map, String id) { factory Group.fromMap(Map<String, dynamic> map, String id) {
return Group( return Group(
id: id, id: id,

View File

@@ -1,20 +1,52 @@
import 'dart:convert'; import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
/// Model representing a travel trip in the application.
///
/// This class encapsulates all trip-related information including dates,
/// location, participants, and budget details. It provides serialization
/// methods for Firebase operations and supports trip lifecycle management.
class Trip { class Trip {
/// Unique identifier for the trip (usually Firestore document ID).
final String? id; final String? id;
/// Title or name of the trip.
final String title; final String title;
/// Detailed description of the trip.
final String description; final String description;
/// Trip destination or location.
final String location; final String location;
/// Trip start date and time.
final DateTime startDate; final DateTime startDate;
/// Trip end date and time.
final DateTime endDate; final DateTime endDate;
/// Optional budget for the trip in the local currency.
final double? budget; final double? budget;
/// List of participant user IDs.
final List<String> participants; final List<String> participants;
/// User ID of the trip creator.
final String createdBy; final String createdBy;
/// Timestamp when the trip was created.
final DateTime createdAt; final DateTime createdAt;
/// Timestamp when the trip was last updated.
final DateTime updatedAt; final DateTime updatedAt;
/// Current status of the trip (e.g., 'draft', 'active', 'completed').
final String status; final String status;
/// Creates a new [Trip] instance.
///
/// Most fields are required except [id] and [budget].
/// [status] defaults to 'draft' for new trips.
Trip({ Trip({
this.id, this.id,
required this.title, required this.title,

View File

@@ -1,11 +1,27 @@
import 'dart:convert'; import 'dart:convert';
/// Model representing a user in the travel mate application.
///
/// This class encapsulates user information including personal details
/// and provides methods for serialization/deserialization with Firebase
/// and JSON operations.
class User { class User {
/// Unique identifier for the user (usually Firebase UID).
final String? id; final String? id;
/// User's last name.
final String nom; final String nom;
/// User's first name.
final String prenom; final String prenom;
/// User's email address.
final String email; final String email;
/// Creates a new [User] instance.
///
/// [nom], [prenom], and [email] are required fields.
/// [id] is optional and typically assigned by Firebase.
User({ User({
this.id, this.id,
required this.nom, required this.nom,
@@ -13,7 +29,9 @@ class User {
required this.email, required this.email,
}); });
// Constructeur pour créer un User depuis un Map (utile pour Firebase) /// Creates a [User] instance from a Map (useful for Firebase operations).
///
/// Handles null values gracefully by providing empty string defaults.
factory User.fromMap(Map<String, dynamic> map) { factory User.fromMap(Map<String, dynamic> map) {
return User( return User(
id: map['id'], id: map['id'],
@@ -23,13 +41,17 @@ class User {
); );
} }
// Constructeur pour créer un User depuis JSON /// Creates a [User] instance from a JSON string.
///
/// Parses the JSON and delegates to [fromMap] for object creation.
factory User.fromJson(String jsonStr) { factory User.fromJson(String jsonStr) {
Map<String, dynamic> map = json.decode(jsonStr); Map<String, dynamic> map = json.decode(jsonStr);
return User.fromMap(map); return User.fromMap(map);
} }
// Méthode pour convertir un User en Map (utile pour Firebase) /// Converts the [User] instance to a Map (useful for Firebase operations).
///
/// Returns a map with all user properties for database storage.
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@@ -39,15 +61,20 @@ class User {
}; };
} }
// Méthode pour convertir un User en JSON /// Converts the [User] instance to a JSON string.
///
/// Useful for API communications and data serialization.
String toJson() { String toJson() {
return json.encode(toMap()); return json.encode(toMap());
} }
// Méthode pour obtenir le nom complet /// Gets the user's full name in "first last" format.
String get fullName => '$prenom $nom'; String get fullName => '$prenom $nom';
// Méthode pour créer une copie avec des modifications /// Creates a copy of this user with optionally modified properties.
///
/// Allows updating specific fields while preserving others.
/// Useful for state management and partial updates.
User copyWith({ User copyWith({
String? id, String? id,
String? nom, String? nom,
@@ -62,17 +89,22 @@ class User {
); );
} }
/// Returns a string representation of the user.
@override @override
String toString() { String toString() {
return 'User(id: $id, nom: $nom, prenom: $prenom, email: $email)'; return 'User(id: $id, nom: $nom, prenom: $prenom, email: $email)';
} }
/// Compares users based on email address.
///
/// Two users are considered equal if they have the same email.
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is User && other.email == email; return other is User && other.email == email;
} }
/// Hash code based on email address.
@override @override
int get hashCode => email.hashCode; int get hashCode => email.hashCode;
} }

View File

@@ -4,7 +4,13 @@ import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart'; import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart'; import '../blocs/auth/auth_state.dart';
/// Login page widget for user authentication.
///
/// This page provides a user interface for signing in with email and password,
/// as well as options for social sign-in (Google, Apple) and password reset.
/// It integrates with the AuthBloc to handle authentication state management.
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
/// Creates a new [LoginPage].
const LoginPage({super.key}); const LoginPage({super.key});
@override @override
@@ -12,9 +18,16 @@ class LoginPage extends StatefulWidget {
} }
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
/// Form key for validation
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Controller for email input field
final _emailController = TextEditingController(); final _emailController = TextEditingController();
/// Controller for password input field
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
/// Whether the password field should hide its content
bool _obscurePassword = true; bool _obscurePassword = true;
@override @override
@@ -24,24 +37,36 @@ class _LoginPageState extends State<LoginPage> {
super.dispose(); super.dispose();
} }
/// Validates email input.
///
/// Returns an error message if the email is invalid or empty,
/// null if the email is valid.
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return 'Email requis'; return 'Email required';
} }
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value.trim())) { if (!emailRegex.hasMatch(value.trim())) {
return 'Email invalide'; return 'Invalid email';
} }
return null; return null;
} }
/// Validates password input.
///
/// Returns an error message if the password is empty,
/// null if the password is valid.
String? _validatePassword(String? value) { String? _validatePassword(String? value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Mot de passe requis'; return 'Password required';
} }
return null; return null;
} }
/// Handles the login process.
///
/// Validates the form and dispatches a sign-in event to the AuthBloc
/// if all fields are valid.
void _login(BuildContext context) { void _login(BuildContext context) {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;

View File

@@ -3,23 +3,49 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
/// Repository for authentication operations and user data management.
///
/// This repository handles authentication logic and user data persistence
/// by coordinating between Firebase Auth and Firestore. It provides a
/// clean interface for authentication operations while managing the
/// relationship between Firebase users and application user data.
class AuthRepository { class AuthRepository {
/// Authentication service for Firebase Auth operations.
final AuthService _authService; final AuthService _authService;
/// Firestore instance for user data operations.
final FirebaseFirestore _firestore; final FirebaseFirestore _firestore;
/// Creates a new [AuthRepository] with optional service dependencies.
///
/// If [authService] or [firestore] are not provided, default instances will be used.
AuthRepository({ AuthRepository({
AuthService? authService, AuthService? authService,
FirebaseFirestore? firestore, FirebaseFirestore? firestore,
}) : _authService = authService ?? AuthService(), }) : _authService = authService ?? AuthService(),
_firestore = firestore ?? FirebaseFirestore.instance; _firestore = firestore ?? FirebaseFirestore.instance;
// Vérifier l'état de connexion actuel /// Stream of authentication state changes.
///
/// Emits Firebase user objects when authentication state changes.
Stream<firebase_auth.User?> get authStateChanges => Stream<firebase_auth.User?> get authStateChanges =>
_authService.authStateChanges; _authService.authStateChanges;
/// Gets the currently authenticated Firebase user.
///
/// Returns null if no user is currently authenticated.
firebase_auth.User? get currentUser => _authService.currentUser; firebase_auth.User? get currentUser => _authService.currentUser;
// Connexion avec email/mot de passe /// Signs in a user with email and password.
///
/// Authenticates with Firebase Auth and retrieves the corresponding
/// user data from Firestore.
///
/// [email] - User's email address
/// [password] - User's password
///
/// Returns the [User] model if successful, null if user data not found.
/// Throws an exception if authentication fails.
Future<User?> signInWithEmailAndPassword({ Future<User?> signInWithEmailAndPassword({
required String email, required String email,
required String password, required String password,
@@ -31,11 +57,22 @@ class AuthRepository {
); );
return await getUserFromFirestore(firebaseUser.user!.uid); return await getUserFromFirestore(firebaseUser.user!.uid);
} catch (e) { } catch (e) {
throw Exception('Erreur de connexion: $e'); throw Exception('Sign-in error: $e');
} }
} }
// Inscription avec email/mot de passe /// Creates a new user account with email and password.
///
/// Creates a Firebase Auth account and stores additional user information
/// in Firestore.
///
/// [email] - User's email address
/// [password] - User's password
/// [nom] - User's last name
/// [prenom] - User's first name
///
/// Returns the created [User] model if successful.
/// Throws an exception if account creation fails.
Future<User?> signUpWithEmailAndPassword({ Future<User?> signUpWithEmailAndPassword({
required String email, required String email,
required String password, required String password,
@@ -48,7 +85,7 @@ class AuthRepository {
password: password, password: password,
); );
// Créer le document utilisateur dans Firestore // Create user document in Firestore with additional information
final user = User( final user = User(
id: firebaseUser.user!.uid, id: firebaseUser.user!.uid,
email: email, email: email,
@@ -60,29 +97,35 @@ class AuthRepository {
return user; return user;
} catch (e) { } catch (e) {
throw Exception('Erreur d\'inscription: $e'); throw Exception('Registration error: $e');
} }
} }
// Connexion avec Google /// Signs in a user using Google authentication.
///
/// Handles Google sign-in flow and creates/retrieves user data from Firestore.
/// If the user doesn't exist, creates a new user document.
///
/// Returns the [User] model if successful, null if Google sign-in was cancelled.
/// Throws an exception if authentication fails.
Future<User?> signInWithGoogle() async { Future<User?> signInWithGoogle() async {
try { try {
final firebaseUser = await _authService.signInWithGoogle(); final firebaseUser = await _authService.signInWithGoogle();
if (firebaseUser.user != null) { if (firebaseUser.user != null) {
// Vérifier si l'utilisateur existe déjà // Check if user already exists in Firestore
final existingUser = await getUserFromFirestore(firebaseUser.user!.uid); final existingUser = await getUserFromFirestore(firebaseUser.user!.uid);
if (existingUser != null) { if (existingUser != null) {
return existingUser; return existingUser;
} }
// Créer un nouvel utilisateur // Create new user document for first-time Google sign-in
final user = User( final user = User(
id: firebaseUser.user!.uid, id: firebaseUser.user!.uid,
email: firebaseUser.user!.email ?? '', email: firebaseUser.user!.email ?? '',
nom: '', nom: '',
prenom: firebaseUser.user!.displayName ?? 'Utilisateur', prenom: firebaseUser.user!.displayName ?? 'User',
); );
await _firestore.collection('users').doc(user.id).set(user.toMap()); await _firestore.collection('users').doc(user.id).set(user.toMap());
@@ -90,11 +133,17 @@ class AuthRepository {
} }
return null; return null;
} catch (e) { } catch (e) {
throw Exception('Erreur de connexion Google: $e'); throw Exception('Google sign-in error: $e');
} }
} }
// Connexion avec Apple /// Signs in a user using Apple authentication.
///
/// Handles Apple sign-in flow and creates/retrieves user data from Firestore.
/// If the user doesn't exist, creates a new user document.
///
/// Returns the [User] model if successful, null if Apple sign-in was cancelled.
/// Throws an exception if authentication fails.
Future<User?> signInWithApple() async { Future<User?> signInWithApple() async {
try { try {
final firebaseUser = await _authService.signInWithApple(); final firebaseUser = await _authService.signInWithApple();
@@ -110,7 +159,7 @@ class AuthRepository {
id: firebaseUser.user!.uid, id: firebaseUser.user!.uid,
email: firebaseUser.user!.email ?? '', email: firebaseUser.user!.email ?? '',
nom: '', nom: '',
prenom: firebaseUser.user!.displayName ?? 'Utilisateur', prenom: firebaseUser.user!.displayName ?? 'User',
); );
await _firestore.collection('users').doc(user.id).set(user.toMap()); await _firestore.collection('users').doc(user.id).set(user.toMap());
@@ -118,21 +167,34 @@ class AuthRepository {
} }
return null; return null;
} catch (e) { } catch (e) {
throw Exception('Erreur de connexion Apple: $e'); throw Exception('Apple sign-in error: $e');
} }
} }
// Déconnexion /// Signs out the current user.
///
/// Clears the authentication state and signs out from Firebase Auth.
Future<void> signOut() async { Future<void> signOut() async {
await _authService.signOut(); await _authService.signOut();
} }
// Réinitialisation du mot de passe /// Sends a password reset email to the specified email address.
///
/// [email] - The email address to send the reset link to
///
/// Throws an exception if the operation fails.
Future<void> resetPassword(String email) async { Future<void> resetPassword(String email) async {
await _authService.resetPassword(email); await _authService.resetPassword(email);
} }
// Récupérer les données utilisateur depuis Firestore /// Retrieves user data from Firestore by user ID.
///
/// Fetches the user document from the 'users' collection and converts
/// it to a [User] model.
///
/// [uid] - The Firebase user ID to look up
///
/// Returns the [User] model if found, null otherwise.
Future<User?> getUserFromFirestore(String uid) async { Future<User?> getUserFromFirestore(String uid) async {
try { try {
final doc = await _firestore.collection('users').doc(uid).get(); final doc = await _firestore.collection('users').doc(uid).get();

View File

@@ -2,17 +2,35 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
/// Repository for user data operations in Firestore.
///
/// This repository provides methods for CRUD operations on user data,
/// including retrieving users by ID or email, updating user information,
/// and managing user profiles in the Firestore database.
class UserRepository { class UserRepository {
/// Firestore instance for database operations.
final FirebaseFirestore _firestore; final FirebaseFirestore _firestore;
/// Authentication service for user-related operations.
final AuthService _authService; final AuthService _authService;
/// Creates a new [UserRepository] with optional dependencies.
///
/// If [firestore] or [authService] are not provided, default instances will be used.
UserRepository({ UserRepository({
FirebaseFirestore? firestore, FirebaseFirestore? firestore,
AuthService? authService, AuthService? authService,
}) : _firestore = firestore ?? FirebaseFirestore.instance, }) : _firestore = firestore ?? FirebaseFirestore.instance,
_authService = authService ?? AuthService(); _authService = authService ?? AuthService();
// Récupérer un utilisateur par ID /// Retrieves a user by their unique ID.
///
/// Fetches the user document from Firestore and converts it to a [User] model.
///
/// [uid] - The unique user identifier
///
/// Returns the [User] model if found, null otherwise.
/// Throws an exception if the operation fails.
Future<User?> getUserById(String uid) async { Future<User?> getUserById(String uid) async {
try { try {
final doc = await _firestore.collection('users').doc(uid).get(); final doc = await _firestore.collection('users').doc(uid).get();
@@ -22,11 +40,19 @@ class UserRepository {
} }
return null; return null;
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e'); throw Exception('Error retrieving user: $e');
} }
} }
// Récupérer un utilisateur par email /// Retrieves a user by their email address.
///
/// Searches the users collection for a matching email address.
/// Email comparison is case-insensitive after trimming whitespace.
///
/// [email] - The email address to search for
///
/// Returns the first [User] found with the matching email, null if not found.
/// Throws an exception if the operation fails.
Future<User?> getUserByEmail(String email) async { Future<User?> getUserByEmail(String email) async {
try { try {
final querySnapshot = await _firestore final querySnapshot = await _firestore
@@ -42,11 +68,17 @@ class UserRepository {
} }
return null; return null;
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la recherche de l\'utilisateur: $e'); throw Exception('Error searching for user: $e');
} }
} }
// Mettre à jour un utilisateur /// Updates an existing user in Firestore.
///
/// Updates the user document with the provided user data.
///
/// [user] - The user object containing updated information
///
/// Returns true if the update was successful, false otherwise.
Future<bool> updateUser(User user) async { Future<bool> updateUser(User user) async {
try { try {
await _firestore.collection('users').doc(user.id).update(user.toMap()); await _firestore.collection('users').doc(user.id).update(user.toMap());

View File

@@ -2,14 +2,32 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';
/// Service for handling Firebase authentication operations.
///
/// This service provides methods for user authentication including email/password
/// sign-in, Google sign-in, Apple sign-in, password reset, and account management.
/// It acts as a wrapper around Firebase Auth functionality.
class AuthService { class AuthService {
/// Error service for logging authentication errors.
final _errorService = ErrorService(); final _errorService = ErrorService();
/// Firebase Auth instance for authentication operations.
final FirebaseAuth firebaseAuth = FirebaseAuth.instance; final FirebaseAuth firebaseAuth = FirebaseAuth.instance;
/// Gets the currently authenticated user.
///
/// Returns null if no user is currently signed in.
User? get currentUser => firebaseAuth.currentUser; User? get currentUser => firebaseAuth.currentUser;
/// Stream that emits authentication state changes.
///
/// Emits the current user when authenticated, null when not authenticated.
Stream<User?> get authStateChanges => firebaseAuth.authStateChanges(); Stream<User?> get authStateChanges => firebaseAuth.authStateChanges();
/// Signs in a user with email and password.
///
/// Returns a [UserCredential] containing the authenticated user's information.
/// Throws [FirebaseAuthException] if authentication fails.
Future<UserCredential> signInWithEmailAndPassword({ Future<UserCredential> signInWithEmailAndPassword({
required String email, required String email,
required String password required String password
@@ -18,6 +36,10 @@ class AuthService {
email: email, password: password); email: email, password: password);
} }
/// Creates a new user account with email and password.
///
/// Returns a [UserCredential] containing the new user's information.
/// Throws [FirebaseAuthException] if account creation fails.
Future<UserCredential> signUpWithEmailAndPassword({ Future<UserCredential> signUpWithEmailAndPassword({
required String email, required String email,
required String password required String password
@@ -26,52 +48,88 @@ class AuthService {
email: email, password: password); email: email, password: password);
} }
/// Signs out the current user.
///
/// Clears the authentication state and signs out the user from Firebase.
Future<void> signOut() async { Future<void> signOut() async {
await firebaseAuth.signOut(); await firebaseAuth.signOut();
} }
/// Sends a password reset email to the specified email address.
///
/// The user will receive an email with instructions to reset their password.
/// Throws [FirebaseAuthException] if the email is invalid or other errors occur.
Future<void> resetPassword(String email) async { Future<void> resetPassword(String email) async {
await firebaseAuth.sendPasswordResetEmail(email: email); await firebaseAuth.sendPasswordResetEmail(email: email);
} }
/// Updates the display name of the current user.
///
/// Requires a user to be currently authenticated.
/// Throws if no user is signed in.
Future<void> updateDisplayName({ Future<void> updateDisplayName({
required String displayName, required String displayName,
}) async { }) async {
await currentUser!.updateDisplayName(displayName); await currentUser!.updateDisplayName(displayName);
} }
/// Deletes the current user's account permanently.
///
/// Requires re-authentication with the user's current password for security.
/// This operation cannot be undone.
///
/// [password] - The user's current password for re-authentication
/// [email] - The user's email address for re-authentication
Future<void> deleteAccount({ Future<void> deleteAccount({
required String password, required String password,
required String email, required String email,
}) async { }) async {
// Re-authenticate the user // Re-authenticate the user for security
AuthCredential credential = AuthCredential credential =
EmailAuthProvider.credential(email: email, password: password); EmailAuthProvider.credential(email: email, password: password);
await currentUser!.reauthenticateWithCredential(credential); await currentUser!.reauthenticateWithCredential(credential);
// Delete the user // Delete the user account permanently
await currentUser!.delete(); await currentUser!.delete();
await firebaseAuth.signOut(); await firebaseAuth.signOut();
} }
/// Resets the user's password after re-authentication.
///
/// This method allows users to change their password by providing their
/// current password for security verification.
///
/// [currentPassword] - The user's current password for verification
/// [newPassword] - The new password to set
/// [email] - The user's email address for re-authentication
Future<void> resetPasswordFromCurrentPassword({ Future<void> resetPasswordFromCurrentPassword({
required String currentPassword, required String currentPassword,
required String newPassword, required String newPassword,
required String email, required String email,
}) async { }) async {
// Re-authenticate the user // Re-authenticate the user for security
AuthCredential credential = AuthCredential credential =
EmailAuthProvider.credential(email: email, password: currentPassword); EmailAuthProvider.credential(email: email, password: currentPassword);
await currentUser!.reauthenticateWithCredential(credential); await currentUser!.reauthenticateWithCredential(credential);
// Update the password // Update to the new password
await currentUser!.updatePassword(newPassword); await currentUser!.updatePassword(newPassword);
} }
/// Ensures Google Sign-In is properly initialized.
///
/// This method must be called before attempting Google authentication.
Future<void> ensureInitialized(){ Future<void> ensureInitialized(){
return GoogleSignInPlatform.instance.init(const InitParameters()); return GoogleSignInPlatform.instance.init(const InitParameters());
} }
/// Signs in a user using Google authentication.
///
/// Handles the complete Google Sign-In flow including platform initialization
/// and credential exchange with Firebase.
///
/// Returns a [UserCredential] containing the authenticated user's information.
/// Throws various exceptions if authentication fails.
Future<UserCredential> signInWithGoogle() async { Future<UserCredential> signInWithGoogle() async {
try { try {
await ensureInitialized(); await ensureInitialized();
@@ -86,24 +144,28 @@ class AuthService {
final OAuthCredential credential = GoogleAuthProvider.credential(idToken: idToken); final OAuthCredential credential = GoogleAuthProvider.credential(idToken: idToken);
UserCredential userCredential = await firebaseAuth.signInWithCredential(credential); UserCredential userCredential = await firebaseAuth.signInWithCredential(credential);
// Retourner le UserCredential au lieu de void // Return the UserCredential instead of void
return userCredential; return userCredential;
} }
} on GoogleSignInException catch (e) { } on GoogleSignInException catch (e) {
_errorService.logError('Erreur Google Sign-In: $e', StackTrace.current); _errorService.logError('Google Sign-In error: $e', StackTrace.current);
rethrow; rethrow;
} on FirebaseAuthException catch (e) { } on FirebaseAuthException catch (e) {
_errorService.logError('Erreur Firebase lors de l\'initialisation de Google Sign-In: $e', StackTrace.current); _errorService.logError('Firebase error during Google Sign-In initialization: $e', StackTrace.current);
rethrow; rethrow;
} catch (e) { } catch (e) {
_errorService.logError('Erreur inconnue lors de l\'initialisation de Google Sign-In: $e', StackTrace.current); _errorService.logError('Unknown error during Google Sign-In initialization: $e', StackTrace.current);
rethrow; rethrow;
} }
} }
/// Signs in a user using Apple authentication.
///
/// TODO: Implement Apple Sign-In functionality
/// This method is currently a placeholder for future Apple authentication support.
Future signInWithApple() async { Future signInWithApple() async {
// TODO: Implémenter la connexion avec Apple // TODO: Implement Apple sign-in
} }
} }

View File

@@ -1,18 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../components/error/error_content.dart'; import '../components/error/error_content.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 { class ErrorService {
static final ErrorService _instance = ErrorService._internal(); static final ErrorService _instance = ErrorService._internal();
/// Factory constructor that returns the singleton instance.
factory ErrorService() => _instance; factory ErrorService() => _instance;
/// Private constructor for singleton pattern.
ErrorService._internal(); ErrorService._internal();
// GlobalKey pour accéder au context depuis n'importe où /// 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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// Afficher une erreur en dialog /// 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
/// [icon] - Icon to display in the dialog
/// [iconColor] - Color of the icon
void showError({ void showError({
required String message, required String message,
String title = 'Erreur', String title = 'Error',
VoidCallback? onRetry, VoidCallback? onRetry,
IconData icon = Icons.error_outline, IconData icon = Icons.error_outline,
Color? iconColor, Color? iconColor,
@@ -30,7 +52,14 @@ class ErrorService {
} }
} }
// Afficher une erreur en snackbar /// 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
void showSnackbar({ void showSnackbar({
required String message, required String message,
VoidCallback? onRetry, VoidCallback? onRetry,
@@ -45,7 +74,7 @@ class ErrorService {
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
action: onRetry != null action: onRetry != null
? SnackBarAction( ? SnackBarAction(
label: 'Réessayer', label: 'Retry',
textColor: Colors.white, textColor: Colors.white,
onPressed: onRetry, onPressed: onRetry,
) )
@@ -59,10 +88,17 @@ class ErrorService {
} }
} }
// Logger dans la console (développement) /// 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]) { void logError(String source, dynamic error, [StackTrace? stackTrace]) {
print('═══════════════════════════════════'); print('═══════════════════════════════════');
print('❌ ERREUR dans $source'); print('❌ ERROR in $source');
print('Message: $error'); print('Message: $error');
if (stackTrace != null) { if (stackTrace != null) {
print('StackTrace: $stackTrace'); print('StackTrace: $stackTrace');
@@ -70,12 +106,18 @@ class ErrorService {
print('═══════════════════════════════════'); print('═══════════════════════════════════');
} }
// Logger une info (développement) /// 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) { void logInfo(String source, String message) {
print(' [$source] $message'); print(' [$source] $message');
} }
// Logger un succès /// 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) { void logSuccess(String source, String message) {
print('✅ [$source] $message'); print('✅ [$source] $message');
} }

View File

@@ -2,13 +2,27 @@ import 'dart:io';
import '../models/expense.dart'; import '../models/expense.dart';
import '../repositories/expense_repository.dart'; import '../repositories/expense_repository.dart';
import 'error_service.dart'; import 'error_service.dart';
import 'storage_service.dart'; // Pour upload des reçus import 'storage_service.dart';
/// Service for managing expense operations with business logic validation.
///
/// This service provides high-level expense management functionality including
/// validation, receipt image uploading, and coordination with the expense repository.
/// It acts as a business logic layer between the UI and data persistence.
class ExpenseService { class ExpenseService {
/// Repository for expense data operations.
final ExpenseRepository _expenseRepository; final ExpenseRepository _expenseRepository;
/// Service for error handling and logging.
final ErrorService _errorService; final ErrorService _errorService;
/// Service for handling file uploads (receipts).
final StorageService _storageService; final StorageService _storageService;
/// Creates a new [ExpenseService] with required dependencies.
///
/// [expenseRepository] is required for data operations.
/// [errorService] and [storageService] have default implementations if not provided.
ExpenseService({ ExpenseService({
required ExpenseRepository expenseRepository, required ExpenseRepository expenseRepository,
ErrorService? errorService, ErrorService? errorService,
@@ -17,13 +31,22 @@ class ExpenseService {
_errorService = errorService ?? ErrorService(), _errorService = errorService ?? ErrorService(),
_storageService = storageService ?? StorageService(); _storageService = storageService ?? StorageService();
// Création avec validation et upload d'image /// Creates an expense with validation and optional receipt image upload.
///
/// Validates the expense data, uploads receipt image if provided, and
/// creates the expense record in the database.
///
/// [expense] - The expense data to create
/// [receiptImage] - Optional receipt image file to upload
///
/// Returns the ID of the created expense.
/// Throws exceptions if validation fails or creation errors occur.
Future<String> createExpenseWithValidation(Expense expense, File? receiptImage) async { Future<String> createExpenseWithValidation(Expense expense, File? receiptImage) async {
try { try {
// Validation métier // Business logic validation
_validateExpenseData(expense); _validateExpenseData(expense);
// Upload du reçu si présent // Upload receipt image if provided
String? receiptUrl; String? receiptUrl;
if (receiptImage != null) { if (receiptImage != null) {
receiptUrl = await _storageService.uploadReceiptImage( receiptUrl = await _storageService.uploadReceiptImage(

View File

@@ -1,3 +1,29 @@
/// A service that handles file storage operations using Firebase Storage.
///
/// This service provides functionality for:
/// - Receipt image upload and compression
/// - Profile image management
/// - File validation and optimization
/// - Automatic image compression to reduce storage costs
/// - Metadata management for uploaded files
///
/// The service automatically compresses images to JPEG format with 85% quality
/// to balance file size and image quality. It also generates unique filenames
/// and handles error logging through the ErrorService.
///
/// 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);
/// ```
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
@@ -5,32 +31,55 @@ import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'error_service.dart'; import 'error_service.dart';
/// Service for managing file storage operations with Firebase Storage.
class StorageService { class StorageService {
/// Firebase Storage instance for file operations
final FirebaseStorage _storage; final FirebaseStorage _storage;
/// Service for error handling and logging
final ErrorService _errorService; 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({ StorageService({
FirebaseStorage? storage, FirebaseStorage? storage,
ErrorService? errorService, ErrorService? errorService,
}) : _storage = storage ?? FirebaseStorage.instance, }) : _storage = storage ?? FirebaseStorage.instance,
_errorService = errorService ?? ErrorService(); _errorService = errorService ?? ErrorService();
/// Upload d'une image de reçu pour une dépense /// 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<String> containing the download URL of the uploaded image
///
/// Throws:
/// Exception if file validation fails or upload encounters an error
Future<String> uploadReceiptImage(String groupId, File imageFile) async { Future<String> uploadReceiptImage(String groupId, File imageFile) async {
try { try {
// Validation du fichier // File validation
_validateImageFile(imageFile); _validateImageFile(imageFile);
// Compression de l'image // Image compression
final compressedImage = await _compressImage(imageFile); final compressedImage = await _compressImage(imageFile);
// Génération du nom de fichier unique // Generate unique filename
final fileName = _generateReceiptFileName(groupId); final fileName = _generateReceiptFileName(groupId);
// Référence vers le storage // Storage reference
final storageRef = _storage.ref().child('receipts/$groupId/$fileName'); final storageRef = _storage.ref().child('receipts/$groupId/$fileName');
// Métadonnées pour optimiser le cache et la compression // Metadata for cache optimization and compression info
final metadata = SettableMetadata( final metadata = SettableMetadata(
contentType: 'image/jpeg', contentType: 'image/jpeg',
customMetadata: { customMetadata: {
@@ -40,58 +89,77 @@ class StorageService {
}, },
); );
// Upload du fichier // File upload
final uploadTask = storageRef.putData(compressedImage, metadata); final uploadTask = storageRef.putData(compressedImage, metadata);
// Monitoring du progrès (optionnel) // Progress monitoring (optional)
uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) { uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
_errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%'); _errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%');
}); });
// Attendre la completion // Wait for completion
final snapshot = await uploadTask; final snapshot = await uploadTask;
// Récupérer l'URL de téléchargement // Get download URL
final downloadUrl = await snapshot.ref.getDownloadURL(); final downloadUrl = await snapshot.ref.getDownloadURL();
_errorService.logSuccess('StorageService', 'Image uploadée avec succès: $fileName'); _errorService.logSuccess('StorageService', 'Image uploaded successfully: $fileName');
return downloadUrl; return downloadUrl;
} catch (e) { } catch (e) {
_errorService.logError('StorageService', 'Erreur upload image: $e'); _errorService.logError('StorageService', 'Error uploading image: $e');
rethrow; rethrow;
} }
} }
/// Supprimer une image de reçu /// 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<void> deleteReceiptImage(String imageUrl) async { Future<void> deleteReceiptImage(String imageUrl) async {
try { try {
if (imageUrl.isEmpty) return; if (imageUrl.isEmpty) return;
// Extraire la référence depuis l'URL // Extract reference from URL
final ref = _storage.refFromURL(imageUrl); final ref = _storage.refFromURL(imageUrl);
await ref.delete(); await ref.delete();
_errorService.logSuccess('StorageService', 'Image supprimée avec succès'); _errorService.logSuccess('StorageService', 'Image deleted successfully');
} catch (e) { } catch (e) {
_errorService.logError('StorageService', 'Erreur suppression image: $e'); _errorService.logError('StorageService', 'Error deleting image: $e');
// Ne pas rethrow pour éviter de bloquer la suppression de la dépense // Don't rethrow to avoid blocking expense deletion
} }
} }
/// Compresser une image pour optimiser le stockage /// 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<Uint8List> containing the compressed image bytes
///
/// Throws:
/// Exception if the image cannot be decoded or processed
Future<Uint8List> _compressImage(File imageFile) async { Future<Uint8List> _compressImage(File imageFile) async {
try { try {
// Lire l'image // Read image
final bytes = await imageFile.readAsBytes(); final bytes = await imageFile.readAsBytes();
img.Image? image = img.decodeImage(bytes); img.Image? image = img.decodeImage(bytes);
if (image == null) { if (image == null) {
throw Exception('Impossible de décoder l\'image'); throw Exception('Unable to decode image');
} }
// Redimensionner si l'image est trop grande // Resize if image is too large
const maxWidth = 1024; const maxWidth = 1024;
const maxHeight = 1024; const maxHeight = 1024;
@@ -104,50 +172,78 @@ class StorageService {
); );
} }
// Encoder en JPEG avec compression // Encode as JPEG with compression
final compressedBytes = img.encodeJpg(image, quality: 85); final compressedBytes = img.encodeJpg(image, quality: 85);
_errorService.logInfo('StorageService', _errorService.logInfo('StorageService',
'Image compressée: ${bytes.length}${compressedBytes.length} bytes'); 'Image compressed: ${bytes.length}${compressedBytes.length} bytes');
return Uint8List.fromList(compressedBytes); return Uint8List.fromList(compressedBytes);
} catch (e) { } catch (e) {
_errorService.logError('StorageService', 'Erreur compression image: $e'); _errorService.logError('StorageService', 'Error compressing image: $e');
// Fallback: retourner l'image originale si la compression échoue // Fallback: return original image if compression fails
return await imageFile.readAsBytes(); return await imageFile.readAsBytes();
} }
} }
/// Valider le fichier image /// 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) { void _validateImageFile(File imageFile) {
// Vérifier que le fichier existe // Check if file exists
if (!imageFile.existsSync()) { if (!imageFile.existsSync()) {
throw Exception('Le fichier image n\'existe pas'); throw Exception('Image file does not exist');
} }
// Vérifier la taille du fichier (max 10MB) // Check file size (max 10MB)
const maxSizeBytes = 10 * 1024 * 1024; // 10MB const maxSizeBytes = 10 * 1024 * 1024; // 10MB
final fileSize = imageFile.lengthSync(); final fileSize = imageFile.lengthSync();
if (fileSize > maxSizeBytes) { if (fileSize > maxSizeBytes) {
throw Exception('La taille du fichier dépasse 10MB'); throw Exception('File size exceeds 10MB limit');
} }
// Vérifier l'extension // Check file extension
final extension = path.extension(imageFile.path).toLowerCase(); final extension = path.extension(imageFile.path).toLowerCase();
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp']; const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
if (!allowedExtensions.contains(extension)) { if (!allowedExtensions.contains(extension)) {
throw Exception('Format d\'image non supporté. Utilisez JPG, PNG ou WebP'); throw Exception('Unsupported image format. Use JPG, PNG or WebP');
} }
} }
/// Générer un nom de fichier unique pour un reçu /// 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) { String _generateReceiptFileName(String groupId) {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecond; final random = DateTime.now().microsecond;
return 'receipt_${groupId}_${timestamp}_$random.jpg'; return 'receipt_${groupId}_${timestamp}_$random.jpg';
} }
/// Upload multiple d'images (pour futures fonctionnalités) /// 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<List<String>> containing download URLs of uploaded images
Future<List<String>> uploadMultipleImages( Future<List<String>> uploadMultipleImages(
String groupId, String groupId,
List<File> imageFiles, List<File> imageFiles,

View File

@@ -1,10 +1,42 @@
/// A service that handles trip-related business logic and data operations.
///
/// This service provides functionality for:
/// - Trip creation, updating, and deletion
/// - Trip validation and business rule enforcement
/// - Location-based trip suggestions
/// - Trip statistics and analytics
/// - Integration with Firebase Firestore for data persistence
///
/// The service ensures data integrity by validating trip information
/// before database operations and provides comprehensive error handling
/// and logging through the ErrorService.
///
/// Example usage:
/// ```dart
/// final tripService = TripService();
///
/// // Create a new trip
/// final tripId = await tripService.createTrip(newTrip);
///
/// // Get trip suggestions for a location
/// final suggestions = await tripService.getTripSuggestions('Paris');
///
/// // Calculate trip statistics
/// final stats = await tripService.getTripStatistics(tripId);
/// ```
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';
import '../models/trip.dart'; import '../models/trip.dart';
/// Service for managing trip-related operations and business logic.
class TripService { class TripService {
/// Service for error handling and logging
final _errorService = ErrorService(); final _errorService = ErrorService();
/// Firestore instance for database operations
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Collection name for trips in Firestore
static const String _tripsCollection = 'trips'; static const String _tripsCollection = 'trips';
// Charger tous les voyages // Charger tous les voyages

View File

@@ -3,29 +3,53 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';
import '../blocs/user/user_state.dart'; import '../blocs/user/user_state.dart';
/// Service for managing user operations with Firestore and Firebase Auth.
///
/// This service provides functionality for user management including creating,
/// retrieving, updating, and deleting user data in Firestore. It also handles
/// user authentication state and provides methods for user profile management.
class UserService { class UserService {
/// Error service for logging user operation errors.
final _errorService = ErrorService(); final _errorService = ErrorService();
/// Firestore instance for database operations.
final FirebaseFirestore _firestore; final FirebaseFirestore _firestore;
/// Firebase Auth instance for user authentication.
final FirebaseAuth _auth; final FirebaseAuth _auth;
/// Collection name for users in Firestore.
static const String _usersCollection = 'users'; static const String _usersCollection = 'users';
/// Creates a new [UserService] with optional Firestore and Auth instances.
///
/// If [firestore] or [auth] are not provided, the default instances will be used.
UserService({ UserService({
FirebaseFirestore? firestore, FirebaseFirestore? firestore,
FirebaseAuth? auth, FirebaseAuth? auth,
}) : _firestore = firestore ?? FirebaseFirestore.instance, }) : _firestore = firestore ?? FirebaseFirestore.instance,
_auth = auth ?? FirebaseAuth.instance; _auth = auth ?? FirebaseAuth.instance;
// Obtenir l'utilisateur connecté actuel /// Gets the currently authenticated Firebase user.
///
/// Returns the [User] object if authenticated, null otherwise.
User? getCurrentFirebaseUser() { User? getCurrentFirebaseUser() {
return _auth.currentUser; return _auth.currentUser;
} }
// Obtenir l'ID de l'utilisateur connecté /// Gets the ID of the currently authenticated user.
///
/// Returns the user ID string if authenticated, null otherwise.
String? getCurrentUserId() { String? getCurrentUserId() {
return _auth.currentUser?.uid; return _auth.currentUser?.uid;
} }
// Créer un nouvel utilisateur dans Firestore /// Creates a new user document in Firestore.
///
/// Takes a [UserModel] object and stores it in the users collection.
/// Returns true if successful, false if an error occurs.
///
/// [user] - The user model to create in Firestore
Future<bool> createUser(UserModel user) async { Future<bool> createUser(UserModel user) async {
try { try {
await _firestore await _firestore
@@ -34,12 +58,16 @@ class UserService {
.set(user.toJson()); .set(user.toJson());
return true; return true;
} catch (e) { } catch (e) {
_errorService.logError('Erreur lors de la création de l\'utilisateur: $e', StackTrace.current); _errorService.logError('Error creating user: $e', StackTrace.current);
return false; return false;
} }
} }
// Obtenir un utilisateur par son ID /// Retrieves a user by their ID from Firestore.
///
/// Returns a [UserModel] if the user exists, null otherwise.
///
/// [userId] - The ID of the user to retrieve
Future<UserModel?> getUserById(String userId) async { Future<UserModel?> getUserById(String userId) async {
try { try {
final doc = await _firestore final doc = await _firestore