Live Demo

Flutter Auth Kit

Flutter Clean Architecture BLoC GetIt GoRouter

Production-ready auth architecture — backend-agnostic, fully testable, and swap-ready from mock to Firebase or REST in one file.

README
flutter_bloc · get_it · go_router · dartz · equatable

Architecture

Presentation
BLoCPagesWidgets
Data
ModelsSourcesRepository Impl
Domain
EntitiesRepository InterfaceUse Cases

Domain is pure Dart — no Flutter, no external packages. Outer layers implement its contracts; the dependency direction never reverses.

Project Structure

lib/
├── core/
│   ├── di/         injection.dart
│   ├── router/     app_router.dart
│   ├── stubs/      mock_auth_remote_source.dart
│   └── theme/      app_colors.dart · app_theme.dart
└── features/auth/
    ├── domain/     entities · repository interface · use cases
    ├── data/       models · sources (abstract) · repository impl
    └── presentation/
        ├── bloc/   auth_bloc · auth_event · auth_state
        ├── pages/  login_page · register_page
        └── widgets auth_text_field · auth_error_banner

Running the App

flutter pub get
flutter run

Runs on MockAuthRemoteSource — simulates 1.2s latency. Sessions reset on restart by design.

Swapping to a Real Backend

// main.dart — Firebase
configureDependencies(
  remoteSource: FirebaseAuthRemoteSource(),
  localSource:  FirebaseAuthLocalSource(),
);

// main.dart — REST + JWT
configureDependencies(
  remoteSource: ApiAuthRemoteSource(dio: Dio()),
  localSource:  SecureStorageLocalSource(),
);

Error Flow

Source throws AuthException
    │
    ▼  AuthRepositoryImpl maps to typed Failure
    │
    ▼  Returned as Left(failure) via Either<Failure, T>
    │
    ▼  AuthBloc.fold() emits AuthError(failure)
    │
    ▼  AuthErrorBanner renders failure.message

Authentication Flow

App Start
    │
    ▼  SplashPage shown (AuthInitial → AuthLoading)
    │
    ▼  SessionCheckRequested dispatched on startup
AuthBloc.checkSession()
    │
    ├── Session found & valid ──► Authenticated ──► Role-based dashboard
    │
    └── No session / expired ──► Unauthenticated ──► LoginPage
                                        │
                                  User submits form
                                        │
                             SignInRequested dispatched
                                        │
                               AuthBloc processes
                                        │
                            ┌───────────┴───────────┐
                        Success                  Failure
                            │                       │
                     Authenticated             AuthError
                     state emitted            state emitted
                            │                       │
                  GoRouter redirect           Error banner
                  to role dashboard          shown in form

Role-Based Routing

UserRole.admin  ──► /dashboard/admin  ──► AdminDashboardPage
UserRole.user   ──► /dashboard/user   ──► UserDashboardPage
UserRole.guest  ──► /dashboard/user   ──► UserDashboardPage

Redirect logic lives entirely in AppRouter._redirect. Adding a new role is a single case in a switch expression — no scattered guard logic anywhere.

Session Management

On every app start, SessionCheckRequested is dispatched. AuthRepositoryImpl.checkSession() reads the cached UserModel and AuthSessionModel from AuthLocalSource, then delegates expiry to the domain:

// domain/entities/auth_session.dart
bool get isExpired => DateTime.now().isAfter(expiresAt);

// data/repositories/auth_repository_impl.dart
Future<Either<Failure, AuthResult>> checkSession() async {
  final user    = await localSource.getCachedUser();
  final session = await localSource.getCachedSession();
  if (user == null || session == null)
    return Left(SessionFailure('No cached session'));
  if (session.isExpired) {
    await localSource.clearCache();
    return Left(SessionFailure('Session expired'));
  }
  return Right(AuthResult(user, session));
}

Dependency Injection

GetIt is the service locator. Only two abstract interfaces need concrete implementations. Everything above them — repository, use cases, BLoC, UI — is unchanged.

// core/di/injection.dart
void configureDependencies({
  required AuthRemoteSource remoteSource,  // ← only seam 1
  required AuthLocalSource  localSource,   // ← only seam 2
}) {
  // Singletons: sources + repository (one instance for app lifetime)
  sl.registerSingleton<AuthRemoteSource>(remoteSource);
  sl.registerSingleton<AuthLocalSource>(localSource);
  sl.registerSingleton<AuthRepository>(
    AuthRepositoryImpl(sl(), sl()),
  );
  // Factories: use cases + BLoC (new instance per resolution)
  sl.registerFactory(() => SignInUseCase(sl()));
  sl.registerFactory(() => AuthBloc(signIn: sl(), ...));
}

Design Decisions

Why BLoC? Strict event → state pipeline makes complex async flows (startup session checks, login, logout) explicit and traceable. The UI emits events and renders states — nothing more.
Why Either? Exceptions are invisible in signatures. Either<Failure, T> forces callers to handle the error path explicitly — fewer silent failures in the UI.
Why abstract source interfaces? Remote and local sources evolve independently. Swap the remote source (Firebase → API) without touching the local cache strategy, and vice versa.
Why GoRouter? refreshListenable wired to the BLoC stream keeps navigation declaratively in sync with auth state. No manual Navigator.push scattered across pages.
Why factory BLoC registration? A new AuthBloc instance per resolution keeps BLoC lifecycle independent of the service locator — no stale state leaking across sessions.