Skip to content

Repository files navigation

Flutter Production Starter

A portfolio-grade Flutter starter built around a small e-commerce catalog demo — not a counter app. It exists to show, in one repo, what a production Flutter codebase actually looks like: layered Clean Architecture, real dependency injection, a resilient API client with token refresh, typed error handling end to end, code-gen Riverpod state, unit + widget + integration tests, flavor-based environments, and CI.

If you're evaluating this as a portfolio piece: the interesting parts are lib/core/network/auth_interceptor.dart (401 → silent refresh → retry → sign-out), the Either<Failure, T> contract used everywhere in lib/core/error/failure.dart, and the get_it/Riverpod DI seam described below.

Why this exists

Most Flutter starters either stay a toy (counter++) or hide their architecture inside a large, opinionated framework. This one is small enough to read end to end in an afternoon, but wires up the same patterns you'd defend in a production codebase review — and documents why each one was chosen (see docs/adr/).

Demo app

A small catalog app backed by two real services:

  • Auth — Firebase Authentication (email/password, Google, Apple).
  • CatalogFakeStoreAPI via a custom Dio client. The Firebase ID token is attached as a Bearer header on catalog requests purely to demonstrate the authenticated-API-client pattern — FakeStoreAPI itself doesn't require it.

Screens: splash/auth-gate, login, register, product list (search + category filter + infinite scroll), product detail, cart (persisted locally), checkout (stubbed "place order" → success screen), profile/logout.

Explicitly out of scope (a deliberate decision, not an omission — see docs/adr/0003-firebase-auth-and-fakestoreapi.md):

  • Real payment gateway integration
  • Localization / i18n
  • Push notifications
  • Offline-first sync
  • Admin/seller features

Architecture

Feature-first Clean Architecture. Every feature is sliced into the same three layers; core/ holds cross-cutting concerns nothing feature-specific belongs in.

lib/
  core/
    di/        get_it setup, called once from bootstrap()
    network/   Dio client, AuthInterceptor, LoggingInterceptor, Failure mapping
    error/     sealed Failure hierarchy
    router/    go_router config + auth redirect guard
    theme/     Material 3 light/dark
    env/       AppEnvironment (typed --dart-define-from-file reader)
    storage/   SecureStorage + LocalKvStore wrappers
  features/
    auth/       data/ domain/ presentation/
    catalog/    data/ domain/ presentation/
    cart/       data/ domain/ presentation/
    checkout/   data/ domain/ presentation/
  app.dart               MaterialApp.router
  bootstrap.dart         shared startup sequence for every flavor
  main_{dev,staging,prod}.dart

Each feature's domain/ layer has zero Flutter or package imports beyond fpdart — pure Dart, unit-testable without mocking anything except its own repository interface.

flowchart LR
    subgraph Presentation
        Screen[Screen / Widget]
        Notifier["@riverpod Notifier<br/>(AsyncValue&lt;T&gt; UI state)"]
    end
    subgraph Domain
        UseCase[Use case]
        RepoIface[Repository interface]
    end
    subgraph Data
        RepoImpl[Repository impl]
        DataSource["Remote/Local data source<br/>(Dio / Firebase / SharedPreferences)"]
    end

    Screen -->|ref.watch / ref.read| Notifier
    Notifier -->|Either fold, no try/catch| UseCase
    UseCase --> RepoIface
    RepoIface -.implemented by.-> RepoImpl
    RepoImpl -->|catches exceptions,<br/>maps to Failure| DataSource
Loading

See docs/architecture.md for layer responsibilities, the full DI graph, and the auth/token-refresh sequence diagram.

Dependency injection: get_it + Riverpod hybrid

  • get_it registers everything non-reactive: the Dio instance, storage wrappers, the Firebase Auth SDK instance, repositories, use cases.
  • Riverpod exposes those get_it registrations to the widget tree via thin Providers, and owns all reactive state via @riverpod code-gen notifiers.
// core/di/injection.dart — get_it owns construction
getIt.registerLazySingleton<AuthRepository>(
  () => AuthRepositoryImpl(getIt<AuthRemoteDataSource>()),
);

// features/auth/presentation/providers/auth_providers.dart — Riverpod exposes it
final authRepositoryProvider = Provider<AuthRepository>((ref) => getIt<AuthRepository>());

Why not one or the other? See docs/adr/0002-get_it-riverpod-hybrid.md.

Error handling

Every repository/use-case method returns Either<Failure, T> (fpdart). Failure is a sealed class (NetworkFailure, ServerFailure, AuthFailure, DataParsingFailure, CacheFailure, ValidationFailure, UnknownFailure) — nothing above the data layer ever sees a DioException or a FirebaseAuthException. Presentation code pattern-matches with .fold; there is no try/catch in any widget or notifier. See docs/adr/0001-fpdart-either-over-exceptions.md.

Tech stack

Concern Choice
State management Riverpod (flutter_riverpod + riverpod_generator, code-gen @riverpod)
DI get_it (services/singletons) + Riverpod (Providers exposing them)
Networking dio, with AuthInterceptor + LoggingInterceptor
Auth firebase_auth, google_sign_in, sign_in_with_apple
Catalog data FakeStoreAPI
Error handling fpdart (Either<Failure, T>)
Routing go_router (redirect guard on auth state)
Local storage flutter_secure_storage (secrets) / shared_preferences (cart)
Images cached_network_image
Testing flutter_test, mocktail, integration_test
CI GitHub Actions

Getting started

Prerequisites

  • Flutter 3.35+ / Dart 3.9+ (flutter --version)
  • A Firebase project (for auth to actually work — see below)

Firebase setup

This repo ships with a placeholder lib/firebase_options.dart — every value in it is a clearly-marked placeholder string, and Firebase calls will fail at runtime (not at build/analyze time) until you replace it:

dart pub global activate flutterfire_cli
flutterfire configure

flutterfire configure will overwrite lib/firebase_options.dart with real values and add the native config files (android/app/google-services.json, ios/Runner/GoogleService-Info.plist). Then, in the Firebase Console:

  1. Authentication → Sign-in method → enable Email/Password, Google, and Apple.
  2. For Google sign-in on iOS/macOS, add the reversed client ID URL scheme from GoogleService-Info.plist to ios/Runner/Info.plist.
  3. For Apple sign-in, enable the Sign in with Apple capability in your Apple Developer account and Xcode target.

Everything above the Firebase SDK boundary — repositories, use cases, notifiers, the AuthInterceptor — is fully unit-tested already and does not require any of this to run flutter test.

Install dependencies

flutter pub get
dart run build_runner build --delete-conflicting-outputs

The codegen step is required — @riverpod notifiers won't compile without their generated *.g.dart files (they're gitignored; CI regenerates them too).

Running per flavor

Three flavors — dev, staging, prod — each with its own env/<flavor>.json (read via --dart-define-from-file, see lib/core/env/app_environment.dart) and, on Android, its own productFlavor (see android/app/build.gradle.kts) so all three can be installed side by side.

flutter run -t lib/main_dev.dart     --flavor dev     --dart-define-from-file=env/dev.json
flutter run -t lib/main_staging.dart --flavor staging --dart-define-from-file=env/staging.json
flutter run -t lib/main_prod.dart    --flavor prod    --dart-define-from-file=env/prod.json

iOS/macOS flavors: Android's Gradle productFlavors are configured in this repo, but the matching Xcode schemes are not — creating them requires editing project.pbxproj, which is safer to do once in Xcode (Product → Scheme → Duplicate, per flavor) than to hand-edit. Building without --flavor on iOS/macOS still works with main_dev.dart etc., it just won't get a distinct bundle ID per environment.

Testing

flutter test                    # unit + widget tests
flutter test --coverage         # with lcov output at coverage/lcov.info
flutter test integration_test/app_test.dart -d macos   # happy-path e2e

The integration test drives login → browse → add to cart → checkout success against the real catalog network client (FakeStoreAPI) and the real cart/checkout stack; only the Firebase Auth SDK call is swapped for an in-memory fake, since this repo's Firebase project is a placeholder (see integration_test/app_test.dart's header comment for why).

Screenshots

Add screenshots or a GIF walkthrough here once you have a configured Firebase project to run against:

Login Product list Cart Checkout
placeholder placeholder placeholder placeholder

Project docs

License

MIT — see LICENSE.

About

Production-oriented Flutter e-commerce starter demonstrating Clean Architecture, Riverpod + get_it DI, Dio with auth refresh, functional error handling with fpdart, Firebase Auth, FakeStoreAPI, testing, flavors, and CI.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages