Skip to content

πŸ” Authentication Module

This module handles all user authentication logic using a clean architecture approach, keeping core business rules isolated from external dependencies.


🧠 Domain Layer

Defines core authentication logic, independent of any frameworks or data sources.

user.dart

Entity representing the authenticated user.

    class User extends Equatable {
        final String id, firstName, lastName, phone, email;
        final bool isEmailVerified;
        final List<String> roles;

        const User({
            required this.id,
            required this.firstName,
            required this.lastName,
            required this.phone,
            required this.email,
            required this.isEmailVerified,
            required this.roles,
        });

        static const empty = User(
            id: '-', firstName: '-', lastName: '-', phone: '-',
            email: '-', isEmailVerified: false, roles: [],
        );

        @override
        List<Object> get props => [id, firstName, lastName, phone, email, isEmailVerified, roles];
    }

auth_repository.dart

Abstract contract for authentication operations.

abstract class AuthRepository {
  Stream<User> getUserStream();
  void dispose();

  Future<Either<Failure, void>> isAuthenticated();
  Future<Either<Failure, void>> login({required String email, required String password});
  Future<Either<Failure, void>> logout();
  Future<Either<Failure, User?>> getUser();
  Future<Either<Failure, void>> register({
    required String firstName,
    required String lastName,
    required String phone,
    required String email,
    required String password,
    required bool iAgree,
  });
}

auth_usecases.dart

Use cases that interact with AuthRepository.

class AuthUsecases {
  final AuthRepository repo;
  AuthUsecases(this.repo);

  Future<Either<Failure, void>> isAuthenticated() => repo.isAuthenticated();
  Stream<User> getUserStream() => repo.getUserStream();
  Future<Either<Failure, void>> logout() => repo.logout();
  void dispose() => repo.dispose();
}

πŸ“¦ Data Layer

The data layer provides the concrete implementation of AuthRepository and bridges the domain layer with external systems (network and local storage).

It uses:

  • πŸ›°οΈ Dio for network calls
  • πŸ“¦ Hive for local caching
  • πŸ” RxDart (BehaviorSubject) for user state streaming

πŸ”§ AuthRepositoryImpl

Implements the AuthRepository contract using Dio, Hive, and network info. Key responsibilities:

Method Description
login Authenticates via /users/login, caches token/user, updates stream
register Sends registration request to /users
getUser Fetches user from API, updates user stream
isAuthenticated Checks user stream or loads from cache
logout Clears Hive boxes and resets user stream
getUserStream() Returns reactive stream of current User
dispose() Closes the stream

πŸ—ƒοΈ Local Helpers:

  • _cacheToken, _cacheUser, _getCachedUser, _clearCache
  • _mapError: maps exceptions to domain Failure objects
Full Implementation: auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
  final Dio dio;
  final HiveInterface hive;
  final NetworkInfo networkInfo;
  final _userController = BehaviorSubject<User>();

  AuthRepositoryImpl({required this.dio, required this.hive, required this.networkInfo});

  @override
  Stream<User> getUserStream() => _userController.stream;

  @override
  Future<Either<Failure, void>> isAuthenticated() async {
    try {
      if (_userController.hasValue) return Right(null);
      final user = await _getCachedUser();
      _userController.add(user);
      return Right(null);
    } catch (_) {
      _userController.add(User.empty);
      return Left(CacheFailure("Failed to load cached user"));
    }
  }

  @override
  Future<Either<Failure, void>> login({required String email, required String password}) async {
    try {
      final res = await dio.post('/users/login', data: {"email": email, "password": password});
      final token = res.data['data']['token'];
      if (token == null) return Left(ServerFailure("Invalid response"));

      await _cacheToken(token);
      final user = await _getUser();
      await _cacheUser(user);
      _userController.add(user);
      return Right(null);
    } catch (e) {
      return Left(_mapError(e));
    }
  }

  @override
  Future<Either<Failure, void>> register({ ... }) async {
    try {
      await dio.post('/users', data: { ... });
      return Right(null);
    } catch (e) {
      return Left(_mapError(e));
    }
  }

  @override
  Future<Either<Failure, User?>> getUser() async {
    try {
      final user = await _getUser();
      _userController.add(user);
      return Right(user);
    } catch (e) {
      return Left(_mapError(e));
    }
  }

  @override
  Future<Either<Failure, void>> logout() async {
    try {
      await _clearCache();
      _userController.add(User.empty);
      return Right(null);
    } catch (_) {
      return Left(CacheFailure("Failed to clear cache"));
    }
  }

  @override
  void dispose() => _userController.close();

  // Helpers
  Future<UserModel> _getUser() async =>
      UserModel.fromJson((await dio.get('/users/me')).data['data']);

  Future<UserModel> _getCachedUser() async =>
      await hive.openLazyBox('userBox').then((b) => b.get('cachedUser') ?? (throw CacheException()));

  Future<void> _cacheToken(String token) async =>
      await hive.openLazyBox('tokenBox').then((b) => b.put('cachedToken', token));

  Future<void> _cacheUser(UserModel user) async =>
      await hive.openLazyBox('userBox').then((b) => b.put('cachedUser', user));

  Future<void> _clearCache() async {
    await hive.openLazyBox('userBox').then((b) => b.clear());
    await hive.openLazyBox('tokenBox').then((b) => b.clear());
  }

  Failure _mapError(dynamic e) {
    if (e is DioException) return ServerFailure(DioExceptions.fromDioError(e).toString());
    if (e is CacheException) return CacheFailure("Caching failed");
    return ServerFailure("Unexpected error");
  }
}

πŸ‘€ UserModel

A Hive-compatible, JSON-deserializable implementation of the domain User. Used for: β€’ parsing /users/me API response β€’ local caching with Hive

@HiveType(typeId: 1)
@JsonSerializable(createToJson: false)
class UserModel extends User {
    @HiveField(0) @JsonKey(name: 'id') final String id;
    @HiveField(1) @JsonKey(name: 'firstName') final String firstName;
    @HiveField(2) @JsonKey(name: 'lastName') final String lastName;
    @HiveField(3) @JsonKey(name: 'phone') final String phone;
    @HiveField(4) @JsonKey(name: 'email') final String email;
    @HiveField(5) @JsonKey(name: 'isEmailVerified', defaultValue: false) final bool isEmailVerified;
    @HiveField(6) @JsonKey(name: 'roles') final List<String> roles;

    const UserModel(
        this.id, this.firstName, this.lastName,
        this.phone, this.email, this.isEmailVerified, this.roles
    ) : super(
        id: id, firstName: firstName, lastName: lastName,
        phone: phone, email: email, isEmailVerified: isEmailVerified, roles: roles);

    factory UserModel.fromJson(Map<String, dynamic> json) => \_$UserModelFromJson(json);
}

πŸ” Global Authentication Bloc

Unlike other feature-specific BLoCs, the AuthBloc is a globally scoped bloc responsible for managing the authenticated user and session status. It persists throughout the entire app lifecycle.

This bloc listens to a User stream exposed by the AuthRepository and emits high-level authentication states.

πŸ“ Path: modules/auth/bloc/auth_bloc.dart

Auth Status Enum

enum AuthStatus {
  unknown,
  authenticated,
  unauthenticated,
  unverified,
}

AuthState

class AuthState extends Equatable {
  final AuthStatus status;
  final User user;

  const AuthState({
    this.status = AuthStatus.unknown,
    this.user = User.empty,
  });

  AuthState copyWith({AuthStatus? status, User? user}) => AuthState(
    status: status ?? this.status,
    user: user ?? this.user,
  );

  @override
  List<Object> get props => [status, user];
}

Auth Events

abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object> get props => [];
}

class AppLoaded extends AuthEvent {}

class AuthStatusSubscriptionRequested extends AuthEvent {}

class AuthLogoutRequested extends AuthEvent {}
AuthBloc Overview
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AuthUsecases _userUsecase;

  AuthBloc({required AuthUsecases userUsecase})
      : _userUsecase = userUsecase,
        super(const AuthState()) {
    on<AppLoaded>(_appLoaded);
    on<AuthStatusSubscriptionRequested>(_onAuthSubscriptionRequested);
    on<AuthLogoutRequested>(_onAuthLogoutRequested);
  }

  Future<void> _appLoaded(...) async {
    await _userUsecase.isAuthenticated();
  }

  Future<void> _onAuthSubscriptionRequested(...) async {
    await _userUsecase.isAuthenticated();
    await emit.forEach<User>(
      _userUsecase.getUserStream(),
      onData: (user) => user == User.empty
        ? state.copyWith(status: AuthStatus.unauthenticated)
        : state.copyWith(status: AuthStatus.authenticated, user: user),
      onError: (_, __) => state.copyWith(status: AuthStatus.unauthenticated),
    );
  }

  void _onAuthLogoutRequested(...) {
    _userUsecase.logout();
  }

  @override
  Future<void> close() {
    _userUsecase.dispose();
    return super.close();
  }
}


🧩 Bloc Registration

The AuthBloc is registered globally via GetIt in auth_module.dart:

di.registerLazySingleton(
  () => AuthBloc(userUsecase: di())..add(AuthStatusSubscriptionRequested()),
);
This dispatch ensures the bloc begins listening to authentication status as soon as the app starts.


🌍 Global Injection in App

Inside app.dart, AuthBloc is injected into the root widget using MultiBlocProvider, ensuring it’s available app-wide:

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider<ThemeModeCubit>(create: (context) => di()),
        BlocProvider<AuthBloc>(create: (context) => di()),
      ],
      child: const AppView(),
    );
  }
}


βœ… This global architecture ensures that:

  • User state is accessible anywhere in the app.
  • Authentication state changes (login, logout, load from cache) automatically update UI and route protections.
  • Lifecycle of AuthBloc matches the app lifecycle β€” initialized on startup and disposed only when the app exits.

🎨 Features Layer

The features/ directory holds UI-related logic and presentation for the authentication module. It is organized into feature-specific subfolders:

features/
β”œβ”€β”€ login/       # Login screen UI and its BLoC
β”œβ”€β”€ register/    # Sign up screen UI and its BLoC
└── profile/     # Profile screen UI

πŸ” Login Flow Example

features/login/bloc/login_bloc.dart

Handles login logic by invoking the AuthUsecases.login(...) method and emitting states based on the result.

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthUsecases _authUsecases;

  LoginBloc({required AuthUsecases authUsecases})
      : _authUsecases = authUsecases,
        super(LoginInitial()) {
    on<LoginSubmitted>(_onSubmitted);
  }

  Future<void> _onSubmitted(
    LoginSubmitted event,
    Emitter<LoginState> emit,
  ) async {
    emit(LoginLoading());
    var result = await _authUsecases.login(
      email: event.email,
      password: event.password,
    );
    emit(result.fold(
      (error) => LoginFailure(error: error.getMessage()),
      (_) => LoginSuccess(),
    ));
  }
}

features/login/page/login_page.dart

Wraps the login form with a BlocProvider and injects LoginBloc from the dependency injector (di()).

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: BlocProvider<LoginBloc>(
        create: (context) => di(),
        child: const Center(child: LoginForm()),
      ),
    );
  }
}

features/login/widgets/login_button.dart

Button that triggers the login submission after validating form data:

BlocBuilder<LoginBloc, LoginState>(
  builder: (context, state) {
    final isLoading = state is LoginLoading;
    return FilledButton(
      onPressed: !isLoading
          ? () {
              if (formKey.currentState?.saveAndValidate() ?? false) {
                context.read<LoginBloc>().add(LoginSubmitted(
                  email: formKey.currentState?.value['email'],
                  password: formKey.currentState?.value['password'],
                ));
              }
            }
          : null,
      child: isLoading
          ? const CircularProgressIndicator()
          : Text(context.tr("loginPage.signIn")),
    );
  },
);

🧩 Module Registration

🧱 auth_module.dart

This file is responsible for registering all dependencies related to the auth module using GetIt.

Future<void> registerAuthModule() async {
  // Hive Adapters
  di<HiveInterface>().registerAdapter<UserModel>(UserModelAdapter());

  // Repository & Usecases
  di.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(dio: di(), hive: di(), networkInfo: di()),
  );
  di.registerLazySingleton<AuthUsecases>(() => AuthUsecases(di()));

  // Global AuthBloc
  di.registerLazySingleton(
    () => AuthBloc(userUsecase: di())..add(AuthStatusSubscriptionRequested()),
  );

  // Feature Blocs
  di.registerFactory(() => LoginBloc(authUsecases: di()));
  di.registerFactory(() => RegisterBloc(authUsecases: di()));

  // Routes
  di<List<RouteBase>>(instanceName: Constants.mainRouesDiKey)
      .addAll(authRoutes());
}

Some auth features like Profile are available as tabs in adaptive layouts. Tabs are injected via registerAuthModuleWithContext, which uses BuildContext to access localized labels.

void registerAuthModuleWithContext(BuildContext context) {
  final navTabs = di<List<AdaptiveDestination>>(
    instanceName: Constants.navTabsDiKey,
  );
  navTabs.addAll(getAuthNavTabs(context));
}

πŸ—‚οΈ auth_routes.dart

Defines all routes and navigation tabs for the auth module.

List<GoRoute> authRoutes() {
  return [
    GoRoute(
      path: "/login",
      redirect: unAuthRouteGuard,
      pageBuilder: (_, __) => const FadeTransitionPage(child: LoginPage()),
    ),
    GoRoute(
      path: "/register",
      redirect: unAuthRouteGuard,
      pageBuilder: (_, __) => const FadeTransitionPage(child: RegisterPage()),
    ),
    GoRoute(
      path: "/profile",
      redirect: authRouteGuard,
      pageBuilder: (_, __) => const FadeTransitionPage(child: ProfilePage()),
    ),
  ];
}

🧭 getAuthNavTabs

Defines tabs to be injected into the adaptive layout.

List<AdaptiveDestination> getAuthNavTabs(BuildContext context) {
  return <AdaptiveDestination>[
    AdaptiveDestination(
      title: context.tr('layoutPage.profile'),
      icon: Icons.person,
      route: '/profile',
      navTab: AuthNavTab.profile,
      order: 30,
    ),
  ];
}

βœ… Summary

The auth module is a fully isolated feature that follows clean architecture principles with clearly defined layers:

  • Domain Layer defines the core business logic and contracts (entities, usecases, repositories).
  • Data Layer implements those contracts using external systems like APIs and local storage.
  • Global Bloc (AuthBloc) listens to authentication state changes and provides app-wide reactive auth state.
  • Features Layer contains UI-specific logic such as login, register, and profile screens with their own BLoCs.
  • Modular Registration allows the module to be independently initialized and plugged into navigation and DI.

This structure ensures:

βœ”οΈ Easy testability and mockability
βœ”οΈ Scalable and maintainable codebase
βœ”οΈ Independent development and replacement of auth flows
βœ”οΈ Centralized user session management via AuthBloc
βœ”οΈ Contextual tab and route registration with GoRouter

This modular approach empowers teams to build and scale large apps while keeping features decoupled and cohesive.