Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save Anderson-Andre-P/019de7c3559ee2e26ac7ba155806f95e to your computer and use it in GitHub Desktop.

Select an option

Save Anderson-Andre-P/019de7c3559ee2e26ac7ba155806f95e to your computer and use it in GitHub Desktop.

The Flutter Architecture & Components Showcase Handbook

A Complete Beginner-to-Advanced Training Guide for Building a Production-Grade Flutter Showcase Application

Who this is for: Someone who has never written Flutter and has never studied software architecture. Every concept is explained from zero, justified, and then implemented. Nothing is assumed.

What you will build: A single Flutter application — the Showcase — made of many independent pages. Each page demonstrates one widget, component, pattern, or architectural concept, all wired into the same Clean Architecture + BLoC backbone. Think of it as a living museum of Flutter where every exhibit is also a lesson in professional structure.

How to read it: Top to bottom the first time. After that, jump to any chapter — each is self-contained and follows the same template.


A Note on the Chapter Template

You asked for a strict per-chapter template (What is it / Why / When to use / When to avoid / Folder Structure / Feature Structure / Layer Responsibilities / BLoC / Events / States / DI / Implementation / Testing / Best Practices / Common Mistakes / Diagrams).

We apply the full template to the architectural chapters (Project Structure, Core, DI, Navigation, Networking, Clean Architecture, State Management, Authentication, Error/Loading States, Testing). These are where the template's sections all carry real meaning.

For the widget-catalog chapters (Forms, Lists, Dialogs, Animations, Layouts, Media, Charts, Calendar, Maps, etc.), forcing all 14 sections onto, say, a Checkbox would produce nonsense ("the BLoC layer of a Checkbox"). Professional documentation adapts. So those chapters use a focused variant: What / Why / When to use / When to avoid / How it fits the architecture / Implementation / Common mistakes / Best practices, and they reuse the architectural backbone established earlier rather than re-deriving a BLoC for every widget. This is itself a best practice — don't repeat structure that doesn't add understanding — and we call it out so the choice is deliberate, not accidental.


Table of Contents

Part I — Foundations

  1. Introduction to Flutter
  2. Project Structure
  3. The Core Layer

Part II — The Architectural Backbone

  1. Clean Architecture
  2. Serialization
  3. Dependency Injection
  4. Networking
  5. State Management with BLoC
  6. Navigation
  7. Error & Loading States

Part III — A Complete Feature, End to End

  1. Authentication

Part IV — The Component Showcase

  1. Forms
  2. Lists
  3. Pagination
  4. Search
  5. Dialogs
  6. Animations
  7. Layouts
  8. Media
  9. File Management
  10. Local Storage
  11. Charts
  12. Calendar
  13. Maps
  14. Shared Components

Part V — Quality & Mastery

  1. Testing
  2. Best Practices
  3. The Complete Application Flow
  4. Glossary

Chapter 1 — Introduction to Flutter

What is it?

Flutter is an open-source framework, made by Google, for building applications from a single codebase that run on Android, iOS, web, Windows, macOS, and Linux. You write your app in the Dart programming language, and Flutter draws every pixel on the screen itself using its own rendering engine (Skia/Impeller). It does not wrap native platform buttons — it paints them — which is why a Flutter app looks and behaves identically everywhere.

Three ideas make Flutter what it is: everything is a widget, the UI is declarative, and the screen is a function of state.

Why does it exist?

Before Flutter, building the "same" app for Android and iOS meant two teams, two languages (Kotlin/Java and Swift/Objective-C), two codebases, and two sets of bugs. Cross-platform attempts that wrapped a web view felt slow and un-native. Flutter's answer: one language, one codebase, one rendering pipeline, near-native performance, and a "hot reload" workflow where code changes appear on screen in under a second. This dramatically lowers the cost of building and maintaining multi-platform apps.

Declarative UI — the single most important mental shift

Coming from older UI toolkits, you may expect imperative UI: you create a label, keep a reference to it, and later call label.setText("Hi") to change it. You are issuing commands to mutate existing objects.

Flutter is declarative. You write a function that, given the current state (the data), describes what the UI should look like right now. When the state changes, Flutter calls your function again and efficiently updates the screen to match the new description. You never mutate widgets; you produce a fresh description and let the framework reconcile the difference.

The classic formula:

UI = f(state)

Read it aloud: "The user interface is a function of state." Change the state, call the function, get a new UI. This is the foundation that makes BLoC (later) feel natural — BLoC's only job is to manage that state.

graph LR
    S[State / Data] -->|f| UI[Widget Tree on screen]
    E[User interaction] -->|changes| S
    S -->|rebuild| UI
    style S fill:#2d6a4f,color:#fff
    style UI fill:#1d3557,color:#fff
Loading

Widgets — everything is a widget

In Flutter, a button is a widget, padding is a widget, the layout that stacks things in a column is a widget, even the entire app is a widget. A widget is an immutable description of a part of the UI. You compose small widgets into bigger ones, forming a widget tree.

There are two foundational kinds:

  • StatelessWidget — describes UI that never changes by itself. Given the same inputs, it always looks the same (e.g., an icon, a label). It has a single build method.
  • StatefulWidget — describes UI that can change over its lifetime internally (e.g., a checkbox toggling). It keeps a companion State object.

Crucial clarification for this handbook: A StatefulWidget is not forbidden. What we forbid (per the architecture rules) is using setState and Provider to manage application/business state. StatefulWidget is still the correct tool for ephemeral UI state that no one else cares about — a TextEditingController, an animation controller, whether a password field is currently obscured. The rule is about where business logic lives, not about banning a Flutter primitive. We'll be precise about this distinction every time it matters.

graph TD
    App[MaterialApp] --> Scaffold
    Scaffold --> AppBar
    Scaffold --> Body[Column]
    Body --> Text
    Body --> Button[ElevatedButton]
    Button --> Label[Text 'Login']
Loading

BuildContext — "where am I in the tree?"

Every build method receives a BuildContext context. Think of it as a handle to the widget's location in the tree. With it, Flutter can answer questions like "what theme applies here?" (Theme.of(context)), "what's the screen size?" (MediaQuery.of(context)), and "which BLoC is provided above me?" (context.read<AuthBloc>()).

Two beginner rules that prevent 90% of BuildContext bugs:

  1. Don't use a context after the widget may be gone. After an await, the widget might have been removed from the tree. Guard with if (!context.mounted) return; before using it.
  2. .of(context) looks upward. It walks up the tree to find the nearest ancestor providing what you asked for. If you ask for something that isn't above you, you get an error — which is why where you provide a BLoC matters.

State management — the heart of the matter

State is any data that can change and that affects what's on screen: the logged-in user, a loading spinner, a list of items, the text in a field. State management is the discipline of deciding where that data lives, how it changes, and how the UI is told to rebuild.

Two categories you must keep separate:

Kind of state Examples Managed by
Ephemeral (UI) state obscure-password toggle, current tab index, scroll position, text controllers The widget itself (StatefulWidget is fine here)
App / Business state auth session, fetched data, success/error/loading of an operation BLoC/Cubit (this is the rule)

Why we ban setState and Provider for business state: setState puts logic inside the widget, tangling UI with business rules, making it untestable without rendering, and unscalable. Provider is a fine tool, but for this project we standardize on BLoC so that every feature looks identical, every piece of logic is testable in isolation, and data flows in one predictable direction.

Why architecture matters

A tiny app survives without architecture. A real one does not. Without structure you get the "big ball of mud": API calls inside button handlers, parsing logic inside widgets, no way to test, and terror every time you touch anything. Architecture is the set of rules about where code goes so that:

  • Change is cheap — swap the API, redesign a screen, replace storage — each touches one place.
  • Work is parallel — many developers, no collisions, because features are isolated.
  • Code is testable — logic lives outside widgets, so you can test it without a screen.
  • New people onboard fast — every feature looks the same.

This handbook teaches Clean Architecture (how to split responsibilities) plus Feature-Based organization (how to split the app), held together by BLoC, Dependency Injection, and GoRouter. The next chapters build that backbone before we add a single showcase page.

Common Mistakes (Introduction)

  • Thinking imperatively. Trying to "find a widget and change it." Instead: change state, let build re-run.
  • Using setState for business logic. It feels easy, then becomes unmaintainable.
  • Using context after await without checking mounted. Causes crashes.
  • Putting everything in one file because "it works for now." It won't later.

Best Practices (Introduction)

  • Keep widgets small and focused; compose rather than nest deeply in one file.
  • Separate ephemeral from business state from day one.
  • Prefer const constructors wherever possible — they let Flutter skip rebuilding unchanged subtrees (a real performance win).
  • Name things for what they are, not how they're built.

Chapter 2 — Project Structure

What is it?

The project structure is the folder and layer layout of the whole application. It is the physical expression of two ideas working together: Clean Architecture (vertical slicing into layers of responsibility) and Feature-Based Architecture (horizontal slicing into self-contained features).

Why does it exist?

Structure exists to make change safe and navigation obvious. When the location of a piece of code is predictable from what it does, you spend zero time hunting and you never accidentally couple unrelated things. A good structure answers, instantly: "If I want to change how login talks to the server, where do I go?" (Answer: features/authentication/data/.)

When should it be used?

Always, for any app you expect to maintain, grow, or test. Even a "small" app that ships to users benefits.

When should it be avoided?

For a 50-line throwaway prototype or a learning spike you will delete tomorrow, this much structure is overhead. Architecture is an investment that pays off over time and size. If neither applies, skip it. (But the moment a prototype becomes "the app," retrofit it.)

The two big ideas

Clean Architecture — slicing by responsibility

Clean Architecture organizes code into concentric layers with one ironclad rule, the Dependency Rule:

Dependencies point only inward. Outer layers know about inner layers; inner layers know nothing about outer layers.

We use three layers, present inside every feature:

Layer Contains Depends on Plain-English job
Domain (innermost) Entities, Use Cases, Repository contracts Nothing — pure Dart The business rules. "What is true regardless of UI or API."
Data (outer) Models, Data Sources, Repository implementations Domain The plumbing. "How we actually fetch/store data."
Presentation (outer) Pages, Widgets, BLoCs Domain The face. "What the user sees and does."
graph TD
    PRES[Presentation<br/>Pages • Widgets • BLoC] -->|calls Use Cases| DOM[Domain<br/>Entities • Use Cases • Contracts]
    DATA[Data<br/>Models • DataSources • Repo Impl] -->|implements contracts| DOM
    DATA --> API[(REST API)]
    DATA --> STG[(Secure Storage)]
    style DOM fill:#2d6a4f,color:#fff
    style PRES fill:#1d3557,color:#fff
    style DATA fill:#6a040f,color:#fff
Loading

Notice both Presentation and Data point at Domain, and never at each other. Domain points at nothing. That is the whole trick — the valuable business rules sit in the center, insulated from frameworks, APIs, and UI fashions.

Feature-Based Architecture — slicing by capability

Instead of grouping by type ("all blocs here, all pages there"), we group by feature: everything about authentication under features/authentication/, everything about forms under features/forms/. You work on features, so a feature should be one place you can read, test, or delete whole.

Dependency Flow & Separation of Concerns

Combine the two ideas and you get a grid: each feature is a column; each layer is a row. Dependencies flow inward (toward Domain) and never sideways (one feature must not import another feature — if two features need the same thing, that thing belongs in core/).

graph TB
    subgraph Feature: Authentication
      AP[Presentation] --> AD[Domain]
      AX[Data] --> AD
    end
    subgraph Feature: Forms
      FP[Presentation] --> FD[Domain]
      FX[Data] --> FD
    end
    AP -. uses .-> CORE[core/]
    FP -. uses .-> CORE
    AX -. uses .-> CORE
    FX -. uses .-> CORE
    style CORE fill:#333,color:#fff
Loading

Folder Structure

lib/
│
├── main.dart                       # Entry point: init DI, run the app.
│
├── core/                           # Shared by ALL features; no business owner.
│   ├── dependency_injection/
│   │   └── injection_container.dart
│   ├── router/
│   │   ├── app_router.dart
│   │   └── app_routes.dart
│   ├── network/
│   │   ├── http_client.dart
│   │   ├── http_status_handler.dart
│   │   └── api_response.dart
│   ├── storage/
│   │   ├── secure_storage_service.dart
│   │   └── cache_service.dart
│   ├── errors/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── usecase/
│   │   ├── usecase.dart
│   │   └── result.dart
│   ├── themes/
│   │   └── app_theme.dart
│   ├── widgets/                    # Reusable, dumb UI (button, field, states).
│   ├── constants/
│   │   └── api_constants.dart
│   ├── extensions/
│   │   └── context_extensions.dart
│   ├── utils/                      # Pure helpers (validators, formatters).
│   └── services/                   # App-wide non-UI services (logger, etc.).
│
└── features/
    ├── authentication/
    │   ├── data/        (datasources/ • models/ • repositories/)
    │   ├── domain/      (entities/ • repositories/ • usecases/)
    │   └── presentation/(bloc/ • pages/ • widgets/)
    ├── forms/
    ├── lists/
    ├── pagination/
    ├── search/
    ├── dialogs/
    ├── animations/
    ├── layouts/
    ├── media/
    ├── charts/
    ├── calendar/
    ├── maps/
    └── catalog/                    # The home screen listing all showcases.

Feature Structure (the repeating unit)

Every feature is the same three-layer slice. Memorize this; you will create it dozens of times:

features/<name>/
├── data/
│   ├── datasources/   # Talk to API/storage. Return Models. Throw Exceptions.
│   ├── models/        # Entities + JSON (fromJson/toJson). Generated .g.dart.
│   └── repositories/  # Implement contracts. Map Model→Entity. Exception→Failure.
├── domain/
│   ├── entities/      # Pure business objects (Equatable). No JSON, no Flutter.
│   ├── repositories/  # Abstract contracts (what, not how).
│   └── usecases/      # One class per business action. The only thing a BLoC calls.
└── presentation/
    ├── bloc/          # <name>_bloc.dart, <name>_event.dart, <name>_state.dart
    ├── pages/         # Full screens.
    └── widgets/       # Feature-specific widgets.

Layer Responsibilities (summary table)

Layer May import May NOT import Throws/Returns
Domain core/errors, core/usecase Flutter, http, json, Data, Presentation Returns Result<Failure, T>
Data Domain, core/network, core/storage, json Presentation Throws Exception; Repo returns Result
Presentation Domain, core/widgets, flutter_bloc Data Emits State

Common Mistakes (Project Structure)

  • Grouping by type instead of feature (/blocs, /pages). Scales badly.
  • Feature importing another feature. Hidden coupling. Promote the shared bit to core/.
  • Domain importing Flutter or http. Breaks the Dependency Rule and ruins testability.
  • A core/ that becomes a junk drawer of feature-specific code.

Best Practices (Project Structure)

  • "If deleting a feature folder leaves the app compiling (minus that feature), your boundaries are right."
  • Keep the same three subfolders in every feature for muscle memory.
  • Put shared things in core/ only when two or more features truly need them — not preemptively.

Chapter 3 — The Core Layer

What is it?

The Core layer is the shared foundation every feature stands on: networking, dependency injection, routing, theming, error types, reusable widgets, utilities, and app-wide services. It is cross-cutting — used everywhere, owned by no single feature.

Why does it exist?

To eliminate duplication and enforce consistency. Without a Core, every feature reinvents HTTP handling, every screen styles its own button, and error handling drifts apart. Core gives the app a single spine.

When should it be used?

For anything reused across features, or anything with no natural feature owner (the HTTP client, the theme, the error vocabulary).

When should it be avoided?

Do not put feature-specific code in Core. A LoginButton styled only for login belongs in the auth feature. The User entity belongs in auth's domain. Ask: "Would a second, unrelated feature use this unchanged?" If no, it's not Core.

What belongs in Core vs. not

✅ Belongs in Core ❌ Does NOT belong in Core
HTTP client, status handler A feature's specific endpoints' parsing
DI container setup
Router + route constants
Theme, colors, typography A one-off screen's custom decoration
Generic widgets (button, field, loading, error, empty) A feature-specific composite widget
Error/Failure base types A feature-specific error message string
Pure utils (validators, formatters) Business rules (those are Use Cases)
Logger, connectivity, secure storage wrappers

Folder-by-folder responsibility

  • dependency_injection/ — the single init() that registers everything with GetIt. (Chapter 6.)
  • router/ — all navigation in one declarative config. (Chapter 9.)
  • network/ — the HTTP wrapper + status-code→exception logic. (Chapter 7.)
  • storage/ — secure storage (secrets) and cache (non-secret) wrappers. (Chapter 21.)
  • errors/exceptions.dart (thrown by Data) and failures.dart (returned to UI). (Chapter 4 & 10.)
  • usecase/ — the UseCase base contract and the Result type. (Chapter 4.)
  • themes/ThemeData so the app looks consistent.
  • widgets/ — dumb, reusable UI components. (Chapter 25.)
  • constants/ — fixed values (base URL, endpoints, durations).
  • extensions/ — convenience methods on existing types (context.theme).
  • utils/ — pure, stateless helpers.
  • services/ — app-wide non-UI services (logging, analytics, connectivity).

Below are the foundational Core files used throughout the rest of the handbook.

Theme

// core/themes/app_theme.dart
import 'package:flutter/material.dart';

class AppColors {
  AppColors._();
  static const primary = Color(0xFF1D3557);
  static const secondary = Color(0xFF457B9D);
  static const background = Color(0xFFF7F9FB);
  static const error = Color(0xFFE63946);
  static const success = Color(0xFF2A9D8F);
}

class AppTheme {
  AppTheme._();
  static ThemeData get light => ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: AppColors.primary,
          primary: AppColors.primary,
          secondary: AppColors.secondary,
          error: AppColors.error,
        ),
        scaffoldBackgroundColor: AppColors.background,
        inputDecorationTheme: const InputDecorationTheme(
          border: OutlineInputBorder(),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            minimumSize: const Size.fromHeight(50),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(10),
            ),
          ),
        ),
      );
}

Constants

// core/constants/api_constants.dart
class ApiConstants {
  ApiConstants._();
  static const String baseUrl = 'https://api.example.com/v1';
  static const Duration timeout = Duration(seconds: 20);
  static const int maxRetries = 3;

  // Endpoints
  static const String login = '/auth/login';
  static const String register = '/auth/register';
  static const String logout = '/auth/logout';
  static const String refresh = '/auth/refresh';
  static const String currentUser = '/auth/me';
  static const String products = '/products';
}

Extensions

// core/extensions/context_extensions.dart
import 'package:flutter/material.dart';

extension ContextX on BuildContext {
  ThemeData get theme => Theme.of(this);
  TextTheme get textTheme => Theme.of(this).textTheme;
  ColorScheme get colors => Theme.of(this).colorScheme;
  Size get screenSize => MediaQuery.sizeOf(this);

  void showSnack(String message) {
    ScaffoldMessenger.of(this)
      ..hideCurrentSnackBar()
      ..showSnackBar(SnackBar(content: Text(message)));
  }
}

A logger service

// core/services/logger_service.dart
import 'dart:developer' as dev;

class LoggerService {
  void info(String m) => dev.log(m, name: 'INFO');
  void warn(String m) => dev.log(m, name: 'WARN');
  void error(String m, [Object? e, StackTrace? s]) =>
      dev.log(m, name: 'ERROR', error: e, stackTrace: s);
}

Common Mistakes (Core)

  • Letting Core depend on a feature (it must depend on nothing feature-specific).
  • A single giant Utils class with 40 unrelated methods — split by purpose.
  • Hard-coding colors/strings in screens instead of theme/constants.

Best Practices (Core)

  • Wrap every third-party package (http, secure storage) behind your own Core class so it can be swapped in one file.
  • Keep Core widgets dumb — no business logic, no BLoC.
  • Centralize user-facing strings to ease future localization.

Chapter 4 — Clean Architecture

What is it?

Clean Architecture is a way of organizing code so that business rules are independent of frameworks, UI, and data sources. It does this with layers and the Dependency Rule (dependencies point inward). In a feature you will see eight kinds of citizens:

  1. Entity — a pure business object.
  2. Model — an Entity plus JSON serialization.
  3. Use Case — one business action.
  4. Repository Contract — an abstract "what we can do."
  5. Repository Implementation — the concrete "how we do it."
  6. Data Source — the lowest-level fetch/store.
  7. Exception — a thrown technical error (Data layer).
  8. Failure — a returned business-friendly error (Domain/Presentation).

Why does it exist?

Because the most valuable, most stable part of an app is its business rules — and the least stable parts are the UI (redesigned constantly) and the data layer (APIs change, databases get swapped). Clean Architecture protects the valuable, stable center from the volatile edges. The payoff is testability (logic runs with no Flutter, no network) and flexibility (swap the edges freely).

When should it be used?

Medium-to-large apps, anything with real business logic, anything that must be tested or maintained by a team. The Showcase uses it everywhere so every feature is uniform.

When should it be avoided?

A trivial app with no business logic (a static brochure) gains little. The cost is more files and indirection; pay it only when the logic justifies it.

Dependency Inversion — the technical heart

Beginners assume "Domain needs the repository to fetch data, so Domain depends on Data." Clean Architecture inverts this:

  • Domain defines an abstract XRepository (method signatures only).
  • Data writes XRepositoryImpl implements XRepository.

So Domain depends only on its own abstraction; Data depends on Domain to fulfill it. This is the D in SOLID — Dependency Inversion — and it's what keeps Domain pure. DI (Chapter 6) supplies the concrete implementation at runtime.

graph LR
    UC[Use Case] --> ABS[AuthRepository<br/>abstract • Domain]
    IMPL[AuthRepositoryImpl<br/>Data] -.implements.-> ABS
    IMPL --> DS[DataSource]
    style ABS fill:#2d6a4f,color:#fff
Loading

Layer Responsibilities

  • Domain must not import Flutter, http, or json. It returns Result<Failure, T> and never throws to its callers.
  • Data does the dirty work: HTTP, JSON, storage. Data sources throw Exceptions; the repository catches them and returns Failures, and maps Models to Entities.
  • Presentation calls Use Cases only, and renders State.

The shared building blocks (core/usecase/)

A small Result type lets us return either success or failure without try/catch leaking upward.

// core/usecase/result.dart
sealed class Result<F, S> {
  const Result();
  T fold<T>(T Function(F failure) onFailure, T Function(S data) onSuccess);
}

class Failed<F, S> extends Result<F, S> {
  final F failure;
  const Failed(this.failure);
  @override
  T fold<T>(T Function(F) onFailure, T Function(S) onSuccess) => onFailure(failure);
}

class Ok<F, S> extends Result<F, S> {
  final S data;
  const Ok(this.data);
  @override
  T fold<T>(T Function(F) onFailure, T Function(S) onSuccess) => onSuccess(data);
}
// core/usecase/usecase.dart
import 'package:equatable/equatable.dart';
import '../errors/failures.dart';
import 'result.dart';

/// Uniform shape: every use case takes Params, returns a Result.
abstract class UseCase<Type, Params> {
  Future<Result<Failure, Type>> call(Params params);
}

class NoParams extends Equatable {
  const NoParams();
  @override
  List<Object?> get props => [];
}

Exceptions vs Failures (the most-missed concept)

Exception Failure
What Low-level technical error Business-friendly error
Where thrown/returned Thrown in Data layer Returned from Repository upward
Travels via throw / try-catch return value (Result)
Seen by UI? Never Yes

The rule: Data throws Exceptions; the Repository converts them to Failures. Above the repository, nobody uses try/catch for these.

// core/errors/exceptions.dart
abstract class AppException implements Exception {
  final String message;
  const AppException(this.message);
  @override
  String toString() => '$runtimeType: $message';
}
class NetworkException extends AppException { const NetworkException(super.m); }
class ServerException extends AppException { const ServerException(super.m); }
class AuthException extends AppException { const AuthException(super.m); }
class NotFoundException extends AppException { const NotFoundException(super.m); }
class ValidationException extends AppException { const ValidationException(super.m); }
class CacheException extends AppException { const CacheException(super.m); }
class UnknownException extends AppException {
  const UnknownException([String m = 'Unknown error.']) : super(m);
}
// core/errors/failures.dart
import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);
  @override
  List<Object?> get props => [message];
}
class NetworkFailure extends Failure { const NetworkFailure([super.m = 'No internet connection.']); }
class ServerFailure extends Failure { const ServerFailure([super.m = 'Server error.']); }
class AuthFailure extends Failure { const AuthFailure([super.m = 'Authentication failed.']); }
class NotFoundFailure extends Failure { const NotFoundFailure([super.m = 'Not found.']); }
class ValidationFailure extends Failure { const ValidationFailure([super.m = 'Invalid data.']); }
class CacheFailure extends Failure { const CacheFailure([super.m = 'Storage error.']); }
class UnknownFailure extends Failure { const UnknownFailure([super.m = 'Unexpected error.']); }

Why Failure extends Equatable: BLoC compares states to decide whether to rebuild. A state often contains a Failure. Value-equality ensures two equal failures don't cause spurious rebuilds and that tests can assert expect(state, AuthError(AuthFailure('x'))).

Complete Implementation (a generic feature skeleton)

Entity → contract → use case (Domain), then Model → data source → repository impl (Data). The full, real version appears in Chapter 11 (Authentication); here is the shape:

// domain/entities/product.dart
import 'package:equatable/equatable.dart';
class Product extends Equatable {
  final String id; final String name; final double price;
  const Product({required this.id, required this.name, required this.price});
  @override
  List<Object?> get props => [id, name, price];
}
// domain/repositories/product_repository.dart
abstract class ProductRepository {
  Future<Result<Failure, List<Product>>> getProducts();
}
// domain/usecases/get_products.dart
class GetProducts implements UseCase<List<Product>, NoParams> {
  final ProductRepository repo;
  const GetProducts(this.repo);
  @override
  Future<Result<Failure, List<Product>>> call(NoParams p) => repo.getProducts();
}

Testing (Clean Architecture)

Each layer is tested in isolation by mocking the layer beneath: Use Case (mock repo), Repository (mock data source + storage; verify Exception→Failure), Data Source (mock http client). Domain tests are pure Dart — no Flutter binding needed. Full examples in Chapter 26.

Best Practices

  • Entities are nouns (User), Use Cases are verbs (LoginUseCase).
  • One Use Case = one action. Don't bundle.
  • The repository is the only place Exceptions become Failures.
  • Register repositories against the abstract type in DI.

Common Mistakes

  • Returning a Model from a Use Case (leak JSON into Domain). Return the Entity.
  • try/catch in the BLoC for network errors (that's the repository's job).
  • Domain importing http/json_annotation.

Mermaid — the eight citizens in motion

graph TD
    BLoC -->|calls| UC[Use Case]
    UC -->|uses contract| REPO[Repository Impl]
    REPO -->|maps Model→Entity| ENT[Entity]
    REPO -->|catch Exception → return Failure| FAIL[Failure]
    REPO --> DS[Data Source]
    DS -->|fromJson| MODEL[Model]
    DS -->|throws| EXC[Exception]
    style UC fill:#2d6a4f,color:#fff
    style REPO fill:#6a040f,color:#fff
Loading

Chapter 5 — Serialization

What is it?

Serialization is converting data between formats. Here specifically: turning a JSON map from the API ({"id":"1","name":"Ada"}) into a Dart object (UserModel), and back. Deserialization is JSON→object (fromJson); serialization is object→JSON (toJson).

Why does it exist?

APIs speak JSON (text). Dart code wants typed objects. Without serialization you'd be reaching into untyped maps everywhere (json['user']['email']), which is fragile — a typo compiles fine and crashes at runtime. Typed models give you autocomplete and compile-time safety.

We generate the conversion code with json_annotation + build_runner rather than hand-writing it, because hand-written parsing is tedious and a top source of bugs.

When should it be used?

Any time data crosses the boundary between your app and the outside world (API, disk). Models live in the Data layer only.

When should it be avoided?

Never serialize Entities (Domain). Entities are pure. Serialization is a Data-layer concern — that separation is what lets the API change without touching business rules.

How it fits Clean Architecture

A Model extends its Entity and adds fromJson/toJson. Because UserModel is-a User, it can be used anywhere a User is expected (Liskov substitution), but the Domain never knows JSON exists.

Complete Implementation

// data/models/user_model.dart
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/user.dart';

part 'user_model.g.dart'; // build_runner generates this.

@JsonSerializable()
class UserModel extends User {
  const UserModel({required super.id, required super.name, required super.email});

  factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);
  Map<String, dynamic> toJson() => _$UserModelToJson(this);

  // Handy when you already have an Entity and need the Model form.
  factory UserModel.fromEntity(User u) =>
      UserModel(id: u.id, name: u.name, email: u.email);
}

Handle API field names that differ from Dart names, and nested objects:

@JsonSerializable(explicitToJson: true)
class ProfileModel extends Profile {
  @JsonKey(name: 'first_name')   // API says first_name; Dart says firstName.
  @override
  final String firstName;

  @JsonKey(name: 'avatar_url', defaultValue: '')
  @override
  final String avatarUrl;

  const ProfileModel({required this.firstName, required this.avatarUrl})
      : super(firstName: firstName, avatarUrl: avatarUrl);

  factory ProfileModel.fromJson(Map<String, dynamic> json) =>
      _$ProfileModelFromJson(json);
  Map<String, dynamic> toJson() => _$ProfileModelToJson(this);
}

Generate the .g.dart files:

# One-off:
dart run build_runner build --delete-conflicting-outputs
# While developing (regenerates on save):
dart run build_runner watch --delete-conflicting-outputs

Mapping Models to Entities

Because UserModel extends User, mapping is often free — just return the model where an entity is expected. When shapes differ, add an explicit toEntity():

User toEntity() => User(id: id, name: name, email: email);

Testing (Serialization)

Test fromJson/toJson round-trips with fixture JSON:

test('fromJson produces correct UserModel', () {
  final json = {'id': '1', 'name': 'Ada', 'email': 'ada@x.com'};
  final model = UserModel.fromJson(json);
  expect(model, const UserModel(id: '1', name: 'Ada', email: 'ada@x.com'));
});

Best Practices

  • explicitToJson: true when you have nested models (otherwise nested toJson isn't called).
  • Provide defaultValue / nullable types for fields the API may omit.
  • Keep generated *.g.dart out of manual edits; regenerate instead.
  • Commit generated files or generate in CI — be consistent.

Common Mistakes

  • Forgetting part 'x.g.dart'; → generator errors.
  • Editing the .g.dart by hand (it'll be overwritten).
  • Putting @JsonSerializable on the Entity (pollutes Domain).
  • Forgetting to re-run build_runner after changing a model.

Mermaid

sequenceDiagram
    participant API
    participant DS as DataSource
    participant M as UserModel
    participant E as User (Entity)
    API-->>DS: JSON map
    DS->>M: UserModel.fromJson(json)
    M-->>DS: typed model (is-a Entity)
    DS-->>E: used as User upward
Loading

Chapter 6 — Dependency Injection

What is it?

Dependency Injection (DI) means an object does not create its own dependencies — they are given to it from outside. A LoginUseCase doesn't build an AuthRepository; the repository is handed to its constructor. A central registry, the Service Locator (get_it), knows how to build everything and hands pieces out on request.

Why does it exist?

To decouple and to make code testable. If AuthBloc built its own real repository, you could never test it without a real server. With DI you inject a fake repository in tests. DI also centralizes object creation so wiring lives in one readable place instead of scattered new calls.

When should it be used?

For any object with dependencies that you want to reuse, swap, or test — repositories, data sources, use cases, BLoCs, the http client, storage.

When should it be avoided?

Trivial value objects and pure functions don't need DI. Don't over-engineer a constant into a registered dependency.

Service Locator & GetIt

A Service Locator is a registry you ask for a ready object: sl<AuthRepository>(). We expose GetIt's instance globally as sl. GetIt needs no BuildContext, so the Domain and Data layers (which have no widgets) can still resolve dependencies — a key reason we chose it over Provider for wiring.

The three registration types

Method Lifetime Use for
registerSingleton<T>(obj) One instance, created now. Something already initialized you need immediately.
registerLazySingleton<T>(() => ...) One instance, created on first use, then reused. Stateless app-long objects: repositories, data sources, http client, storage, use cases. Most common.
registerFactory<T>(() => ...) New instance every call. Objects with per-screen state — BLoCs.

Why BLoCs are factories: a BLoC holds one screen's state. Two screens must not share one instance, or they fight over state. registerFactory gives each screen a fresh BLoC. (Use Cases/repositories are stateless, so singletons are fine and cheaper.)

Feature registrations — organizing the container

One init(), one private _initX() per feature. Keeps a growing file readable.

// core/dependency_injection/injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

import '../network/http_client.dart';
import '../storage/secure_storage_service.dart';
import '../services/logger_service.dart';

// feature imports
import '../../features/authentication/data/datasources/auth_remote_data_source.dart';
import '../../features/authentication/data/repositories/auth_repository_impl.dart';
import '../../features/authentication/domain/repositories/auth_repository.dart';
import '../../features/authentication/domain/usecases/login_usecase.dart';
import '../../features/authentication/domain/usecases/logout_usecase.dart';
import '../../features/authentication/domain/usecases/register_usecase.dart';
import '../../features/authentication/domain/usecases/get_current_user_usecase.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';

final GetIt sl = GetIt.instance;

Future<void> init() async {
  _initCore();
  _initAuthentication();
  // _initForms(); _initLists(); ... one per feature
}

void _initCore() {
  sl.registerLazySingleton<http.Client>(() => http.Client());
  sl.registerLazySingleton<FlutterSecureStorage>(() => const FlutterSecureStorage());
  sl.registerLazySingleton(() => LoggerService());
  sl.registerLazySingleton<SecureStorageService>(() => SecureStorageService(sl()));
  sl.registerLazySingleton<HttpClient>(() => HttpClient(client: sl(), storage: sl()));
}

void _initAuthentication() {
  // BLoC -> factory (fresh per screen)
  sl.registerFactory<AuthBloc>(() => AuthBloc(
        loginUseCase: sl(),
        registerUseCase: sl(),
        logoutUseCase: sl(),
        getCurrentUserUseCase: sl(),
      ));
  // Use cases -> lazy singletons
  sl.registerLazySingleton(() => LoginUseCase(sl()));
  sl.registerLazySingleton(() => RegisterUseCase(sl()));
  sl.registerLazySingleton(() => LogoutUseCase(sl()));
  sl.registerLazySingleton(() => GetCurrentUserUseCase(sl()));
  // Repository -> registered against the ABSTRACT type
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(remoteDataSource: sl(), storage: sl()),
  );
  // Data source
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(client: sl()),
  );
}

sl() with no type argument infers the type from the constructor parameter. Registering the repository against AuthRepository (not ...Impl) is Dependency Inversion in action — callers ask for the abstraction and never import the implementation.

Complete Implementation — using it

In main.dart: await init(); before runApp. In a page: BlocProvider(create: (_) => sl<AuthBloc>()).

Testing (DI)

In tests you usually don't use the real container — you construct the unit under test with mocks directly. For widget tests that resolve via sl, you can register mocks: sl.registerFactory<AuthBloc>(() => mockBloc); and sl.reset() in tearDown.

Best Practices

  • Register against abstractions (AuthRepository), not concretions.
  • BLoC/Cubit = factory; everything stateless = lazy singleton.
  • One _initX() per feature; call them all from init().
  • Call init() once, before runApp.

Common Mistakes

  • Registering a BLoC as a singleton → shared state bugs across screens.
  • Forgetting await init() → "object not registered" at runtime.
  • Resolving sl<T>() deep inside Domain logic — pass dependencies via constructors instead; only the composition root and widgets touch sl.

Mermaid

graph TD
    MAIN[main.dart: init] --> SL[(GetIt registry)]
    PAGE[Page] -->|sl AuthBloc| SL
    SL -->|builds| BLOC
    BLOC --> UC[UseCases] --> REPO[Repository] --> DS[DataSource] --> HTTP[HttpClient]
    SL -->|injects each| BLOC
Loading

Chapter 7 — Networking

What is it?

Networking is how the app talks to a REST API over HTTP. We use the http package, wrapped in our own HttpClient so the rest of the app never touches http directly. The wrapper centralizes base URL, headers (including the auth token), JSON encoding/decoding, timeout, retries, and — critically — turning status codes into Exceptions via a dedicated HttpStatusHandler.

Why does it exist?

Every API call needs the same boilerplate: base URL, JSON headers, bearer token, timeout, error interpretation. Repeating that in every data source is duplication and inconsistency. One wrapper means data sources just call client.get('/products') and receive clean data or a typed exception.

When should it be used?

For all remote data access, from the Data layer's data sources only. Presentation and Domain never make HTTP calls.

When should it be avoided?

Don't call HTTP from widgets or BLoCs. Don't scatter raw http.get around — always go through the wrapper.

HTTP verbs (what each means)

Verb Meaning Typical use
GET Read Fetch a list / item
POST Create Login, create resource
PUT Replace Replace an entire resource
PATCH Partial update Update some fields
DELETE Remove Delete a resource
MULTIPART Upload Send files + fields

HTTP Status Code Handling

Status codes are a language: 2xx success, 4xx "you did something wrong," 5xx "server broke." We centralize their interpretation in one file so the meaning is consistent everywhere.

// core/network/http_status_handler.dart
import '../errors/exceptions.dart';

/// Single source of truth: HTTP status code -> decoded body (success)
/// or a typed Exception (failure).
///
/// Groups: 1xx Informational • 2xx Success • 3xx Redirection
///         4xx Client errors • 5xx Server errors • 6xx+ Custom app codes
class HttpStatusHandler {
  HttpStatusHandler._();

  static dynamic handle(int statusCode, dynamic body) {
    final message = _message(body);

    // 1xx Informational — rarely surfaced; pass through.
    if (statusCode >= 100 && statusCode < 200) return body;

    // 2xx Success — the happy path.
    if (statusCode >= 200 && statusCode < 300) return body;

    // 3xx Redirection — http normally follows these; if seen, it's unexpected.
    if (statusCode >= 300 && statusCode < 400) {
      throw ServerException('Unexpected redirection ($statusCode).');
    }

    // 4xx Client errors.
    if (statusCode >= 400 && statusCode < 500) {
      switch (statusCode) {
        case 400: throw ValidationException(message.isEmpty ? 'Bad request.' : message);
        case 401: throw AuthException(message.isEmpty ? 'Unauthorized.' : message);
        case 403: throw AuthException(message.isEmpty ? 'Forbidden.' : message);
        case 404: throw NotFoundException(message.isEmpty ? 'Not found.' : message);
        case 409: throw ValidationException(message.isEmpty ? 'Conflict.' : message);
        case 422: throw ValidationException(message.isEmpty ? 'Unprocessable entity.' : message);
        default:  throw ServerException('Client error ($statusCode): $message');
      }
    }

    // 5xx Server errors.
    if (statusCode >= 500 && statusCode < 600) {
      throw ServerException(message.isEmpty ? 'Server error ($statusCode).' : message);
    }

    // 6xx+ Custom application codes (some APIs use these for domain signals).
    if (statusCode >= 600) {
      throw ServerException('Application error ($statusCode): $message');
    }

    throw const ServerException('Unknown HTTP status.');
  }

  static String _message(dynamic body) {
    if (body is Map<String, dynamic>) {
      final c = body['message'] ?? body['error'] ?? body['detail'];
      if (c is String) return c;
    }
    return '';
  }
}

The HttpClient (timeout, headers, retry, multipart)

// core/network/http_client.dart
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

import '../constants/api_constants.dart';
import '../errors/exceptions.dart';
import '../storage/secure_storage_service.dart';
import 'http_status_handler.dart';

class HttpClient {
  final http.Client _client;
  final SecureStorageService _storage;
  HttpClient({required http.Client client, required SecureStorageService storage})
      : _client = client, _storage = storage;

  Future<Map<String, String>> _headers() async {
    final h = {'Content-Type': 'application/json', 'Accept': 'application/json'};
    final token = await _storage.getAccessToken();
    if (token != null && token.isNotEmpty) h['Authorization'] = 'Bearer $token';
    return h;
  }

  Uri _uri(String p) => Uri.parse('${ApiConstants.baseUrl}$p');

  Future<dynamic> get(String p)    => _send(() async => _client.get(_uri(p), headers: await _headers()));
  Future<dynamic> delete(String p) => _send(() async => _client.delete(_uri(p), headers: await _headers()));
  Future<dynamic> post(String p, {Map<String, dynamic>? body}) =>
      _send(() async => _client.post(_uri(p), headers: await _headers(), body: jsonEncode(body ?? {})));
  Future<dynamic> put(String p, {Map<String, dynamic>? body}) =>
      _send(() async => _client.put(_uri(p), headers: await _headers(), body: jsonEncode(body ?? {})));
  Future<dynamic> patch(String p, {Map<String, dynamic>? body}) =>
      _send(() async => _client.patch(_uri(p), headers: await _headers(), body: jsonEncode(body ?? {})));

  /// Multipart upload (files + fields).
  Future<dynamic> multipart(String path, {
    required String filePath, String field = 'file', Map<String, String>? fields,
  }) async {
    final req = http.MultipartRequest('POST', _uri(path));
    final headers = await _headers()..remove('Content-Type'); // set by multipart
    req.headers.addAll(headers);
    req.fields.addAll(fields ?? {});
    req.files.add(await http.MultipartFile.fromPath(field, filePath));
    final streamed = await req.send().timeout(ApiConstants.timeout);
    final res = await http.Response.fromStream(streamed);
    final decoded = res.body.isEmpty ? null : jsonDecode(res.body);
    return HttpStatusHandler.handle(res.statusCode, decoded);
  }

  /// Core send: timeout + retry + transport-error mapping + status handling.
  Future<dynamic> _send(Future<http.Response> Function() request) async {
    int attempt = 0;
    while (true) {
      attempt++;
      try {
        final res = await request().timeout(ApiConstants.timeout);
        final decoded = res.body.isEmpty ? null : jsonDecode(res.body);
        return HttpStatusHandler.handle(res.statusCode, decoded);
      } on TimeoutException {
        if (attempt >= ApiConstants.maxRetries) {
          throw const NetworkException('Request timed out.');
        }
        // simple backoff before retrying transient failures
        await Future.delayed(Duration(milliseconds: 300 * attempt));
      } on http.ClientException {
        throw const NetworkException('No internet connection.');
      } on FormatException {
        throw const ServerException('Invalid response format.');
      }
    }
  }
}

Retry policy note: we retry only timeouts (transient). We do not retry 4xx (your request is wrong; retrying won't help) and generally not 5xx automatically unless idempotent — be careful retrying non-idempotent POSTs. This is a deliberate, safe default.

How responses become Exceptions, and Exceptions become Failures

  1. HttpClient sends the request, applies timeout/retry.
  2. HttpStatusHandler reads the status code → returns body (2xx) or throws a typed Exception (4xx/5xx).
  3. The data source lets the Exception propagate (or parses the body).
  4. The repository catches the Exception and returns a Failure.
  5. The BLoC receives Result, emits an error State.
  6. The UI shows the message.
sequenceDiagram
    participant DS as DataSource
    participant H as HttpClient
    participant SH as StatusHandler
    participant R as Repository
    participant B as BLoC
    DS->>H: get('/products')
    H->>SH: handle(status, body)
    alt 2xx
        SH-->>H: body
        H-->>DS: decoded JSON
        DS-->>R: Model(s)
        R-->>B: Ok(Entities)
    else 4xx/5xx
        SH-->>H: throw Exception
        H-->>DS: (propagates)
        DS-->>R: (propagates)
        R-->>B: Failed(Failure)
    end
Loading

Testing (Networking)

Mock http.Client to return crafted http.Responses and assert: 2xx returns body; 401 throws AuthException; 500 throws ServerException; timeout retries then throws NetworkException. (See Chapter 26.)

Best Practices

  • Wrap http; never import it outside core/network.
  • One status handler; never interpret codes ad-hoc in data sources.
  • Sensible timeout; retry only idempotent/transient cases.
  • Attach the token in the client, not in every call site.

Common Mistakes

  • Parsing error bodies inconsistently across data sources.
  • Retrying POSTs blindly (duplicate side effects).
  • Letting raw http exceptions reach the UI.

Chapter 8 — State Management with BLoC

What is it?

BLoC (Business Logic Component) is a pattern where the UI sends Events, a BLoC processes them (calling Use Cases) and emits States, and the UI rebuilds from those states. Cubit is a simpler sibling: same States, but you call methods instead of dispatching Events. Both come from the flutter_bloc package.

Why does it exist?

To get predictable, testable, unidirectional state. Logic lives outside widgets (testable without a screen), and the flow UI → Event → BLoC → State → UI is one-directional, so there's always a single inspectable state and exactly one way to change it.

graph LR
    UI -->|add Event| BLOC
    BLOC -->|call| UC[Use Case]
    UC --> BLOC
    BLOC -->|emit State| UI
    style BLOC fill:#1d3557,color:#fff
Loading

Cubit vs Bloc — when to use which

Cubit Bloc
Trigger Method call (cubit.increment()) Event dispatch (bloc.add(Increment()))
Boilerplate Less More (Event classes)
Best for Simple state, few actions Complex flows, many actions, need an event audit trail / transformEvents (debounce)

Rule of thumb: start with Cubit for simple screens (a counter, a toggle, a single fetch). Use Bloc when you have many distinct actions, need event transformations (debounced search!), or want a traceable event log. The Showcase uses both deliberately and labels which and why.

When should it be avoided?

For ephemeral UI state (obscure-password toggle, current tab) a StatefulWidget is simpler and correct — don't create a BLoC for it. BLoC is for business/app state.

Events, States, and the canonical four states

Almost every data screen needs four states. Model them explicitly:

State Meaning UI
Initial/Loading not started / in progress spinner / skeleton
Success (with data) data arrived content
Empty success but no data empty view
Error failed error view + retry

Treating Empty as distinct from Success prevents the "blank screen, no explanation" bug.

The widget-side tools

Widget Purpose Rebuilds UI? Side effects?
BlocProvider Creates & provides a BLoC to a subtree
BlocBuilder Rebuilds UI from state
BlocListener Runs a callback on state change (navigate, snackbar)
BlocConsumer Builder + Listener combined
BlocSelector Rebuilds only when a selected slice changes (perf) ✅ (narrow)
MultiBlocProvider Provides several BLoCs at once

Builder draws, Listener acts. Never navigate inside a BlocBuilder (it can run many times). Navigate/snackbar inside a BlocListener/BlocConsumer.listener.

Complete Implementation — a Cubit (simple) and a Bloc (complex)

A Counter Cubit (ephemeral-ish demo of the API, simplest form):

// features/state_demo/presentation/cubit/counter_cubit.dart
import 'package:bloc/bloc.dart';
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
}

A Products Bloc with the canonical states:

// products_event.dart
part of 'products_bloc.dart';
sealed class ProductsEvent extends Equatable {
  const ProductsEvent();
  @override List<Object?> get props => [];
}
class ProductsRequested extends ProductsEvent { const ProductsRequested(); }
class ProductsRefreshed extends ProductsEvent { const ProductsRefreshed(); }
// products_state.dart
part of 'products_bloc.dart';
sealed class ProductsState extends Equatable {
  const ProductsState();
  @override List<Object?> get props => [];
}
class ProductsInitial extends ProductsState { const ProductsInitial(); }
class ProductsLoading extends ProductsState { const ProductsLoading(); }
class ProductsEmpty extends ProductsState { const ProductsEmpty(); }
class ProductsLoaded extends ProductsState {
  final List<Product> products;
  const ProductsLoaded(this.products);
  @override List<Object?> get props => [products];
}
class ProductsError extends ProductsState {
  final String message;
  const ProductsError(this.message);
  @override List<Object?> get props => [message];
}
// products_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/product.dart';
import '../../domain/usecases/get_products.dart';
import '../../../../core/usecase/usecase.dart';
part 'products_event.dart';
part 'products_state.dart';

class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
  final GetProducts getProducts;
  ProductsBloc(this.getProducts) : super(const ProductsInitial()) {
    on<ProductsRequested>(_load);
    on<ProductsRefreshed>(_load);
  }
  Future<void> _load(ProductsEvent e, Emitter<ProductsState> emit) async {
    emit(const ProductsLoading());
    final result = await getProducts(const NoParams());
    result.fold(
      (f) => emit(ProductsError(f.message)),
      (list) => emit(list.isEmpty ? const ProductsEmpty() : ProductsLoaded(list)),
    );
  }
}

UI consuming it, handling all four states with a switch:

BlocBuilder<ProductsBloc, ProductsState>(
  builder: (context, state) => switch (state) {
    ProductsLoading() || ProductsInitial() => const AppLoading(),
    ProductsEmpty() => const AppEmptyView(),
    ProductsError(:final message) => AppErrorView(
        message: message,
        onRetry: () => context.read<ProductsBloc>().add(const ProductsRequested()),
      ),
    ProductsLoaded(:final products) => ListView(
        children: [for (final p in products) ListTile(title: Text(p.name))],
      ),
  },
);

Communication between BLoCs

Three approaches, in order of preference:

  1. Shared Use Case / Repository — the cleanest: both BLoCs depend on the same repository; one writes, the other reads. No direct coupling.
  2. BlocListener bridging — a widget listens to BLoC A and dispatches an event to BLoC B. Keeps BLoCs unaware of each other.
  3. Stream subscription — BLoC B subscribes to BLoC A's stream in its constructor (use sparingly; creates coupling — cancel the subscription in close()).
// Approach 2 — bridge in the widget layer:
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthAuthenticated) {
      context.read<CartBloc>().add(CartLoaded(state.user.id));
    }
  },
  child: ...,
);

MultiBlocProvider provides several at once:

MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => sl<AuthBloc>()),
    BlocProvider(create: (_) => sl<CartBloc>()),
  ],
  child: const HomePage(),
);

BlocSelector to rebuild on a slice only (perf):

BlocSelector<CartBloc, CartState, int>(
  selector: (state) => state is CartLoaded ? state.items.length : 0,
  builder: (context, count) => Badge(label: Text('$count')),
);

Testing (BLoC)

bloc_test asserts emitted-state sequences. Full examples in Chapter 26:

blocTest<ProductsBloc, ProductsState>(
  'emits [Loading, Loaded] on success',
  build: () {
    when(() => getProducts(any())).thenAnswer((_) async => Ok([product]));
    return ProductsBloc(getProducts);
  },
  act: (b) => b.add(const ProductsRequested()),
  expect: () => [const ProductsLoading(), ProductsLoaded([product])],
);

Best Practices

  • States immutable + Equatable. Events immutable + Equatable.
  • BLoC calls Use Cases only (never repositories/data sources directly).
  • No BuildContext in a BLoC — keep it testable.
  • One BLoC per feature concern; no god-BLoC.
  • Always handle every branch (loading → success | empty | error).
  • Dispose: BlocProvider auto-closes BLoCs it creates.

Common Mistakes

  • Navigating in BlocBuilder (use BlocListener).
  • Mutating a list in place and re-emitting it — Equatable sees "no change," UI won't rebuild. Emit a new list.
  • Forgetting Equatable props → missed or duplicate rebuilds.
  • Sharing one BLoC instance across screens (register as factory).

Mermaid — unidirectional flow with all four states

stateDiagram-v2
    [*] --> Initial
    Initial --> Loading: Event added
    Loading --> Loaded: data (non-empty)
    Loading --> Empty: data (empty)
    Loading --> Error: failure
    Error --> Loading: retry Event
    Loaded --> Loading: refresh Event
Loading

Chapter 9 — Navigation

What is it?

Navigation is moving between screens. We use GoRouter: a declarative router where all routes live in one config, support names, parameters, nesting, shells, deep links, and redirects (perfect for auth guards).

Why does it exist?

The old Navigator.push(MaterialPageRoute(...)) scattered across widgets is imperative, hard to deep-link, and hard to guard. GoRouter centralizes navigation, supports URLs/deep links out of the box, and lets you block access with one redirect function.

When should it be used?

For all app navigation. Once you adopt GoRouter, never use raw Navigator for top-level navigation.

When should it be avoided?

Local, transient UI navigation that isn't a "screen" — opening a dialog or bottom sheet — uses showDialog/showModalBottomSheet, not routes (unless you want them deep-linkable).

Concepts demonstrated

  • Named routes, route parameters, nested routes, ShellRoute (persistent shell like a bottom nav), deep links, redirects, authentication guards.

Folder structure

core/router/
├── app_routes.dart   # name/path constants
└── app_router.dart    # the GoRouter config
// core/router/app_routes.dart
class AppRoutes {
  AppRoutes._();
  // paths
  static const splash = '/splash';
  static const login = '/login';
  static const register = '/register';
  static const home = '/';
  static const catalog = '/catalog';
  static const productDetail = '/catalog/:id'; // :id is a path param
  static const settings = '/settings';
  // names
  static const loginName = 'login';
  static const registerName = 'register';
  static const homeName = 'home';
  static const catalogName = 'catalog';
  static const productDetailName = 'productDetail';
  static const settingsName = 'settings';
}

Complete Implementation — router with guard, params, nested routes, and a ShellRoute

// core/router/app_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../dependency_injection/injection_container.dart';
import '../storage/secure_storage_service.dart';
import 'app_routes.dart';

import '../../features/authentication/presentation/pages/login_page.dart';
import '../../features/authentication/presentation/pages/register_page.dart';
import '../../features/catalog/presentation/pages/catalog_page.dart';
import '../../features/catalog/presentation/pages/product_detail_page.dart';
import '../../features/catalog/presentation/pages/home_shell.dart';
import '../../features/catalog/presentation/pages/settings_page.dart';

class AppRouter {
  AppRouter._();

  static final GoRouter router = GoRouter(
    initialLocation: AppRoutes.home,
    debugLogDiagnostics: true,

    // ---- AUTH GUARD: runs before every navigation ----
    redirect: (context, state) async {
      final loggedIn = await sl<SecureStorageService>().hasToken();
      final goingToAuth = state.matchedLocation == AppRoutes.login ||
                          state.matchedLocation == AppRoutes.register;
      if (!loggedIn && !goingToAuth) return AppRoutes.login; // protect app
      if (loggedIn && goingToAuth) return AppRoutes.home;     // skip auth pages
      return null; // allow
    },

    routes: [
      GoRoute(path: AppRoutes.login, name: AppRoutes.loginName,
        builder: (_, __) => const LoginPage()),
      GoRoute(path: AppRoutes.register, name: AppRoutes.registerName,
        builder: (_, __) => const RegisterPage()),

      // ---- ShellRoute: a persistent scaffold (e.g. bottom nav) wrapping children ----
      ShellRoute(
        builder: (context, state, child) => HomeShell(child: child),
        routes: [
          GoRoute(path: AppRoutes.home, name: AppRoutes.homeName,
            builder: (_, __) => const CatalogPage(),
            // ---- nested route: /catalog/:id is a child ----
            routes: [
              GoRoute(
                path: 'catalog/:id', name: AppRoutes.productDetailName,
                builder: (context, state) {
                  final id = state.pathParameters['id']!;       // path param
                  final q = state.uri.queryParameters['ref'];   // query param
                  return ProductDetailPage(id: id, ref: q);
                },
              ),
            ],
          ),
          GoRoute(path: AppRoutes.settings, name: AppRoutes.settingsName,
            builder: (_, __) => const SettingsPage()),
        ],
      ),
    ],

    errorBuilder: (_, state) =>
        Scaffold(body: Center(child: Text('Not found: ${state.uri}'))),
  );
}

Navigating between screens

context.goNamed(AppRoutes.homeName);                         // replace stack
context.pushNamed(AppRoutes.productDetailName,              // push (keeps back)
    pathParameters: {'id': '42'}, queryParameters: {'ref': 'list'});
context.pop();                                               // go back

Deep links

Because routes are URL-based, https://app.example.com/catalog/42 (web) or a configured custom scheme (mobile) opens straight to that screen, passing through the same redirect guard. Configure platform link settings (Android intent-filter, iOS Associated Domains) and GoRouter handles the rest — no extra code.

How the whole app uses GoRouter

main.dart uses MaterialApp.router(routerConfig: AppRouter.router). Every navigation then flows through this single config and its redirect guard — so "logged-out users can never reach protected screens" is enforced in exactly one place.

Testing (Navigation)

Pump a widget wrapped in a test GoRouter and assert the correct page appears after goNamed. Test the redirect logic by toggling the storage mock's hasToken.

Best Practices

  • All routes & names as constants (no magic strings).
  • Guard via redirect, not scattered checks in screens.
  • Use goNamed/pushNamed (rename a path in one place).
  • ShellRoute for persistent chrome (bottom nav, side rail).

Common Mistakes

  • Mixing raw Navigator with GoRouter (inconsistent stack).
  • Putting business logic in redirect beyond auth checks.
  • Forgetting context.mounted checks when navigating after await.

Mermaid

graph TD
    NAV[context.goNamed] --> RT[GoRouter]
    RT --> GUARD{redirect: hasToken?}
    GUARD -->|no & protected| LOGIN[LoginPage]
    GUARD -->|yes| SHELL[ShellRoute]
    SHELL --> CAT[CatalogPage]
    CAT --> DET[ProductDetail :id]
Loading

Chapter 10 — Error & Loading States

What is it?

This chapter unifies how the app communicates progress and problems: loading indicators, skeleton/shimmer placeholders, empty states, error states with retry, and offline mode. It also nails down the exception → failure → state → UI pipeline.

Why does it exist?

Users must always know what's happening. A frozen screen with no spinner feels broken; a blank list with no "empty" message looks like a bug; an error with no retry traps the user. Consistent state UI is a hallmark of professional apps.

How exceptions become failures become UI (the full pipeline)

  1. Data source calls HttpClient; on a bad status the HttpStatusHandler throws a typed Exception.
  2. The repository catches it and returns a Failure (technical → business).
  3. The BLoC receives Result.fold(...) and emits a state: LoadingError/Empty/Loaded.
  4. The UI renders the matching widget: AppLoading / AppErrorView / AppEmptyView / content.
sequenceDiagram
    participant DS as DataSource
    participant R as Repository
    participant B as BLoC
    participant UI
    DS->>DS: throw AuthException (401)
    DS-->>R: propagates
    R->>R: catch -> return Failed(AuthFailure)
    R-->>B: Result
    B->>B: emit(Error(message))
    B-->>UI: state
    UI->>UI: show AppErrorView(onRetry)
Loading

The reusable state widgets (Core)

// core/widgets/app_loading.dart
import 'package:flutter/material.dart';
class AppLoading extends StatelessWidget {
  const AppLoading({super.key});
  @override
  Widget build(BuildContext c) => const Center(child: CircularProgressIndicator());
}
// core/widgets/app_error_view.dart
import 'package:flutter/material.dart';
class AppErrorView extends StatelessWidget {
  final String message; final VoidCallback? onRetry;
  const AppErrorView({super.key, required this.message, this.onRetry});
  @override
  Widget build(BuildContext c) => Center(
    child: Column(mainAxisSize: MainAxisSize.min, children: [
      const Icon(Icons.error_outline, size: 48, color: Colors.red),
      const SizedBox(height: 12),
      Text(message, textAlign: TextAlign.center),
      if (onRetry != null) ...[
        const SizedBox(height: 16),
        ElevatedButton(onPressed: onRetry, child: const Text('Retry')),
      ],
    ]),
  );
}
// core/widgets/app_empty_view.dart
import 'package:flutter/material.dart';
class AppEmptyView extends StatelessWidget {
  final String message;
  const AppEmptyView({super.key, this.message = 'Nothing here yet.'});
  @override
  Widget build(BuildContext c) => Center(
    child: Column(mainAxisSize: MainAxisSize.min, children: [
      const Icon(Icons.inbox_outlined, size: 48, color: Colors.grey),
      const SizedBox(height: 12), Text(message),
    ]),
  );
}

Skeleton / Shimmer loading

A skeleton shows gray placeholder shapes where content will appear; shimmer animates a sheen across them. It feels faster than a spinner because it previews layout. Minimal dependency-free shimmer:

// core/widgets/shimmer_box.dart
import 'package:flutter/material.dart';
class ShimmerBox extends StatefulWidget {
  final double height; final double width;
  const ShimmerBox({super.key, this.height = 16, this.width = double.infinity});
  @override State<ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<ShimmerBox> with SingleTickerProviderStateMixin {
  late final AnimationController _c =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat();
  @override void dispose() { _c.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => AnimatedBuilder(
    animation: _c,
    builder: (_, __) => Container(
      height: widget.height, width: widget.width,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        gradient: LinearGradient(
          begin: Alignment(-1 - 2 * _c.value, 0), end: Alignment(1 - 2 * _c.value, 0),
          colors: const [Color(0xFFE0E0E0), Color(0xFFF5F5F5), Color(0xFFE0E0E0)],
        ),
      ),
    ),
  );
}

State transitions: Initial → Loading (skeleton) → Loaded/Empty/Error. Skeletons shine on first load; a small spinner suits refresh/pagination. Don't show a full-screen skeleton on a pull-to-refresh — keep existing content and overlay a subtle indicator.

Retry pattern & Offline mode

  • Retry: the error state carries enough context for the UI to re-dispatch the original event (onRetry: () => bloc.add(Requested())).
  • Offline: a connectivity service in core/services/ exposes a stream; a top-level BlocListener/banner reacts. The repository can also fall back to cache (Chapter 21) when offline, returning cached entities with a "stale" flag.
// core/services/connectivity_service.dart (interface sketch)
abstract class ConnectivityService {
  Stream<bool> get onStatusChange; // true = online
  Future<bool> get isOnline;
}

Testing (Error/Loading)

  • BLoC test: failure path emits [Loading, Error(message)]; empty data emits [Loading, Empty].
  • Widget test: pump each state, assert spinner/error/empty/content appears; tap Retry → verify event dispatched.

Best Practices

  • Always model all four states; never leave a path with no UI.
  • Make errors actionable (retry, or clear guidance).
  • Distinguish Empty from Error from Loading.
  • Keep error messages user-friendly (the Failure.message), not raw exceptions.

Common Mistakes

  • Showing raw exception text/stack to users.
  • Forgetting the empty state (blank screen confusion).
  • Blocking the whole screen on refresh instead of overlaying.
  • Retry that doesn't actually re-run the failed operation.

Chapter 11 — Authentication

This is the flagship feature: it exercises every layer end to end — Login, Registration, Logout, Refresh Token, Session management, and Secure Storage. Study this chapter and you understand the whole architecture in one concrete example.

What is it?

Authentication establishes who the user is and keeps them signed in across app launches. It covers credentials in (login/register), credentials out (logout), and keeping a session alive (refresh token).

Why does it exist?

Most apps gate features behind identity. We need to securely store the session token, attach it to requests, refresh it when it expires, and route the user based on auth state.

When should it be used?

Whenever the app has protected resources or per-user data.

When should it be avoided?

A fully public, anonymous app needs none of it — don't add auth complexity you don't use.

The Secure Storage foundation

Why not SharedPreferences? It stores plain text — readable on a rooted/jailbroken device or via file access. A stolen token is a stolen account. flutter_secure_storage encrypts via the OS Keychain (iOS) / Keystore (Android). Secrets (tokens, passwords) go here, never in SharedPreferences.

// core/storage/secure_storage_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  final FlutterSecureStorage _s;
  SecureStorageService(this._s);

  static const _access = 'access_token';
  static const _refresh = 'refresh_token';

  Future<void> saveAccessToken(String t) => _s.write(key: _access, value: t);
  Future<void> saveRefreshToken(String t) => _s.write(key: _refresh, value: t);
  Future<String?> getAccessToken() => _s.read(key: _access);
  Future<String?> getRefreshToken() => _s.read(key: _refresh);
  Future<bool> hasToken() async {
    final t = await getAccessToken();
    return t != null && t.isNotEmpty;
  }
  Future<void> updateAccessToken(String t) => saveAccessToken(t); // overwrite
  Future<void> clear() => _s.deleteAll();
}

Folder / Feature Structure

features/authentication/
├── data/
│   ├── datasources/auth_remote_data_source.dart
│   ├── models/user_model.dart (+ user_model.g.dart)
│   └── repositories/auth_repository_impl.dart
├── domain/
│   ├── entities/user.dart
│   ├── repositories/auth_repository.dart
│   └── usecases/{login,register,logout,get_current_user,refresh_session}_usecase.dart
└── presentation/
    ├── bloc/{auth_bloc,auth_event,auth_state}.dart
    ├── pages/{login_page,register_page,home_page}.dart
    └── widgets/login_form.dart

Domain layer

// domain/entities/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
  final String id; final String name; final String email;
  const User({required this.id, required this.name, required this.email});
  @override List<Object?> get props => [id, name, email];
}
// domain/repositories/auth_repository.dart
import '../../../../core/errors/failures.dart';
import '../../../../core/usecase/result.dart';
import '../entities/user.dart';
abstract class AuthRepository {
  Future<Result<Failure, User>> login({required String email, required String password});
  Future<Result<Failure, User>> register({required String name, required String email, required String password});
  Future<Result<Failure, void>> logout();
  Future<Result<Failure, User>> getCurrentUser();
  Future<Result<Failure, void>> refreshSession();
}
// domain/usecases/login_usecase.dart
import 'package:equatable/equatable.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/usecase/result.dart';
import '../../../../core/usecase/usecase.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';

class LoginParams extends Equatable {
  final String email; final String password;
  const LoginParams({required this.email, required this.password});
  @override List<Object?> get props => [email, password];
}
class LoginUseCase implements UseCase<User, LoginParams> {
  final AuthRepository repo;
  const LoginUseCase(this.repo);
  @override
  Future<Result<Failure, User>> call(LoginParams p) =>
      repo.login(email: p.email, password: p.password);
}
// domain/usecases/register_usecase.dart
class RegisterParams extends Equatable {
  final String name, email, password;
  const RegisterParams({required this.name, required this.email, required this.password});
  @override List<Object?> get props => [name, email, password];
}
class RegisterUseCase implements UseCase<User, RegisterParams> {
  final AuthRepository repo;
  const RegisterUseCase(this.repo);
  @override
  Future<Result<Failure, User>> call(RegisterParams p) =>
      repo.register(name: p.name, email: p.email, password: p.password);
}
// domain/usecases/logout_usecase.dart
class LogoutUseCase implements UseCase<void, NoParams> {
  final AuthRepository repo; const LogoutUseCase(this.repo);
  @override Future<Result<Failure, void>> call(NoParams _) => repo.logout();
}
// domain/usecases/get_current_user_usecase.dart
class GetCurrentUserUseCase implements UseCase<User, NoParams> {
  final AuthRepository repo; const GetCurrentUserUseCase(this.repo);
  @override Future<Result<Failure, User>> call(NoParams _) => repo.getCurrentUser();
}

Data layer

// data/models/user_model.dart
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/user.dart';
part 'user_model.g.dart';
@JsonSerializable()
class UserModel extends User {
  const UserModel({required super.id, required super.name, required super.email});
  factory UserModel.fromJson(Map<String, dynamic> j) => _$UserModelFromJson(j);
  Map<String, dynamic> toJson() => _$UserModelToJson(this);
}
// data/datasources/auth_remote_data_source.dart
import '../../../../core/constants/api_constants.dart';
import '../../../../core/network/http_client.dart';
import '../models/user_model.dart';

abstract class AuthRemoteDataSource {
  Future<({UserModel user, String access, String refresh})> login(String email, String password);
  Future<({UserModel user, String access, String refresh})> register(String name, String email, String password);
  Future<void> logout();
  Future<UserModel> getCurrentUser();
  Future<String> refresh(String refreshToken);
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final HttpClient client;
  const AuthRemoteDataSourceImpl({required this.client});

  @override
  Future<({UserModel user, String access, String refresh})> login(String email, String password) async {
    final data = await client.post(ApiConstants.login, body: {'email': email, 'password': password});
    final m = data as Map<String, dynamic>;
    return (
      user: UserModel.fromJson(m['user']),
      access: m['access_token'] as String,
      refresh: m['refresh_token'] as String,
    );
  }

  @override
  Future<({UserModel user, String access, String refresh})> register(String name, String email, String password) async {
    final data = await client.post(ApiConstants.register, body: {'name': name, 'email': email, 'password': password});
    final m = data as Map<String, dynamic>;
    return (
      user: UserModel.fromJson(m['user']),
      access: m['access_token'] as String,
      refresh: m['refresh_token'] as String,
    );
  }

  @override
  Future<void> logout() async { await client.post(ApiConstants.logout); }

  @override
  Future<UserModel> getCurrentUser() async =>
      UserModel.fromJson(await client.get(ApiConstants.currentUser));

  @override
  Future<String> refresh(String refreshToken) async {
    final data = await client.post(ApiConstants.refresh, body: {'refresh_token': refreshToken});
    return (data as Map<String, dynamic>)['access_token'] as String;
  }
}
// data/repositories/auth_repository_impl.dart
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/storage/secure_storage_service.dart';
import '../../../../core/usecase/result.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_data_source.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final SecureStorageService storage;
  const AuthRepositoryImpl({required this.remoteDataSource, required this.storage});

  @override
  Future<Result<Failure, User>> login({required String email, required String password}) =>
      _authCall(() => remoteDataSource.login(email, password));

  @override
  Future<Result<Failure, User>> register({required String name, required String email, required String password}) =>
      _authCall(() => remoteDataSource.register(name, email, password));

  Future<Result<Failure, User>> _authCall(
      Future<({UserModel user, String access, String refresh})> Function() call) async {
    try {
      final r = await call();
      await storage.saveAccessToken(r.access);
      await storage.saveRefreshToken(r.refresh);
      return Ok(r.user);
    } on AuthException catch (e) { return Failed(AuthFailure(e.message)); }
      on ValidationException catch (e) { return Failed(ValidationFailure(e.message)); }
      on NetworkException catch (e) { return Failed(NetworkFailure(e.message)); }
      on ServerException catch (e) { return Failed(ServerFailure(e.message)); }
      catch (_) { return const Failed(UnknownFailure()); }
  }

  @override
  Future<Result<Failure, void>> logout() async {
    try {
      await remoteDataSource.logout();
      await storage.clear();
      return const Ok(null);
    } catch (_) {
      await storage.clear(); // ensure local logout regardless
      return const Ok(null);
    }
  }

  @override
  Future<Result<Failure, User>> getCurrentUser() async {
    try {
      return Ok(await remoteDataSource.getCurrentUser());
    } on AuthException catch (e) { return Failed(AuthFailure(e.message)); }
      on NetworkException catch (e) { return Failed(NetworkFailure(e.message)); }
      catch (_) { return const Failed(UnknownFailure()); }
  }

  @override
  Future<Result<Failure, void>> refreshSession() async {
    try {
      final refresh = await storage.getRefreshToken();
      if (refresh == null) return const Failed(AuthFailure('No session.'));
      final newAccess = await remoteDataSource.refresh(refresh);
      await storage.updateAccessToken(newAccess);
      return const Ok(null);
    } on AuthException catch (e) { return Failed(AuthFailure(e.message)); }
      catch (_) { return const Failed(UnknownFailure()); }
  }
}

Refresh-token strategy (session management): when a request returns 401, the app can attempt refreshSession() once; on success retry the original request, on failure force logout. For clarity we keep refresh as an explicit use case here; in production you often centralize it in the HttpClient as an interceptor. Either way, the policy (when to refresh, when to give up) is a single, testable place.

Presentation layer (BLoC)

// presentation/bloc/auth_event.dart
part of 'auth_bloc.dart';
sealed class AuthEvent extends Equatable {
  const AuthEvent(); @override List<Object?> get props => [];
}
class AuthCheckRequested extends AuthEvent { const AuthCheckRequested(); }
class LoginRequested extends AuthEvent {
  final String email, password;
  const LoginRequested({required this.email, required this.password});
  @override List<Object?> get props => [email, password];
}
class RegisterRequested extends AuthEvent {
  final String name, email, password;
  const RegisterRequested({required this.name, required this.email, required this.password});
  @override List<Object?> get props => [name, email, password];
}
class LogoutRequested extends AuthEvent { const LogoutRequested(); }
// presentation/bloc/auth_state.dart
part of 'auth_bloc.dart';
sealed class AuthState extends Equatable {
  const AuthState(); @override List<Object?> get props => [];
}
class AuthInitial extends AuthState { const AuthInitial(); }
class AuthLoading extends AuthState { const AuthLoading(); }
class AuthAuthenticated extends AuthState {
  final User user; const AuthAuthenticated(this.user);
  @override List<Object?> get props => [user];
}
class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
class AuthError extends AuthState {
  final String message; const AuthError(this.message);
  @override List<Object?> get props => [message];
}
// presentation/bloc/auth_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/usecase/usecase.dart';
import '../../domain/entities/user.dart';
import '../../domain/usecases/login_usecase.dart';
import '../../domain/usecases/register_usecase.dart';
import '../../domain/usecases/logout_usecase.dart';
import '../../domain/usecases/get_current_user_usecase.dart';
part 'auth_event.dart';
part 'auth_state.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;
  final RegisterUseCase registerUseCase;
  final LogoutUseCase logoutUseCase;
  final GetCurrentUserUseCase getCurrentUserUseCase;

  AuthBloc({
    required this.loginUseCase, required this.registerUseCase,
    required this.logoutUseCase, required this.getCurrentUserUseCase,
  }) : super(const AuthInitial()) {
    on<AuthCheckRequested>(_onCheck);
    on<LoginRequested>(_onLogin);
    on<RegisterRequested>(_onRegister);
    on<LogoutRequested>(_onLogout);
  }

  Future<void> _onCheck(AuthCheckRequested e, Emitter<AuthState> emit) async {
    emit(const AuthLoading());
    final r = await getCurrentUserUseCase(const NoParams());
    r.fold((_) => emit(const AuthUnauthenticated()), (u) => emit(AuthAuthenticated(u)));
  }
  Future<void> _onLogin(LoginRequested e, Emitter<AuthState> emit) async {
    emit(const AuthLoading());
    final r = await loginUseCase(LoginParams(email: e.email, password: e.password));
    r.fold((f) => emit(AuthError(f.message)), (u) => emit(AuthAuthenticated(u)));
  }
  Future<void> _onRegister(RegisterRequested e, Emitter<AuthState> emit) async {
    emit(const AuthLoading());
    final r = await registerUseCase(RegisterParams(name: e.name, email: e.email, password: e.password));
    r.fold((f) => emit(AuthError(f.message)), (u) => emit(AuthAuthenticated(u)));
  }
  Future<void> _onLogout(LogoutRequested e, Emitter<AuthState> emit) async {
    emit(const AuthLoading());
    final r = await logoutUseCase(const NoParams());
    r.fold((f) => emit(AuthError(f.message)), (_) => emit(const AuthUnauthenticated()));
  }
}

Presentation layer (Pages & Widgets)

// presentation/widgets/login_form.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/utils/validators.dart';
import '../../../../core/widgets/app_button.dart';
import '../../../../core/widgets/app_text_field.dart';
import '../bloc/auth_bloc.dart';

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});
  @override State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  // TextEditingControllers are EPHEMERAL UI state -> StatefulWidget is correct here.
  final _email = TextEditingController();
  final _password = TextEditingController();
  @override void dispose() { _email.dispose(); _password.dispose(); super.dispose(); }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      context.read<AuthBloc>().add(
        LoginRequested(email: _email.text.trim(), password: _password.text));
    }
  }

  @override
  Widget build(BuildContext context) => Form(
    key: _formKey,
    child: Column(children: [
      AppTextField(controller: _email, label: 'Email',
          keyboardType: TextInputType.emailAddress, validator: Validators.email),
      const SizedBox(height: 16),
      AppTextField(controller: _password, label: 'Password',
          obscureText: true, validator: Validators.password),
      const SizedBox(height: 24),
      BlocBuilder<AuthBloc, AuthState>(
        builder: (context, state) => AppButton(
          label: 'Login', isLoading: state is AuthLoading, onPressed: _submit),
      ),
    ]),
  );
}
// presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/dependency_injection/injection_container.dart';
import '../../../../core/extensions/context_extensions.dart';
import '../../../../core/router/app_routes.dart';
import '../bloc/auth_bloc.dart';
import '../widgets/login_form.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});
  @override
  Widget build(BuildContext context) => BlocProvider(
    create: (_) => sl<AuthBloc>(),
    child: Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: BlocListener<AuthBloc, AuthState>(
          listener: (context, state) {
            if (state is AuthAuthenticated) context.goNamed(AppRoutes.homeName);
            if (state is AuthError) context.showSnack(state.message);
          },
          child: const Center(child: LoginForm()),
        ),
      ),
    ),
  );
}

Why BlocListener navigates and BlocBuilder draws the button: Builder can run many times — unsafe for navigation. Listener runs once per state change — perfect for navigation/snackbars. Builder draws, Listener acts.

Dependency Injection (recap)

Registered in _initAuthentication() (see Chapter 6): BLoC as factory, use cases/repository/data source as lazy singletons, repository bound to the abstract AuthRepository.

How the files interact

sequenceDiagram
    actor U as User
    participant F as LoginForm
    participant B as AuthBloc
    participant UC as LoginUseCase
    participant R as AuthRepositoryImpl
    participant DS as RemoteDataSource
    participant H as HttpClient
    participant S as SecureStorage
    participant API
    U->>F: tap Login
    F->>B: add(LoginRequested)
    B->>B: emit(AuthLoading)
    B->>UC: call(LoginParams)
    UC->>R: login(...)
    R->>DS: login(...)
    DS->>H: post(/auth/login)
    H->>API: HTTP POST
    API-->>H: 200 + tokens+user
    H-->>DS: JSON
    DS-->>R: UserModel + tokens
    R->>S: save tokens
    R-->>UC: Ok(User)
    UC-->>B: Ok(User)
    B->>B: emit(AuthAuthenticated)
    B-->>F: state
    F->>F: Listener -> goNamed(home); router guard now sees token
Loading

Testing (Authentication)

  • Use case: mock repo; verify forwarding and result.
  • Repository: mock data source + storage; assert tokens saved on success and AuthException → AuthFailure.
  • BLoC: blocTest for [Loading, Authenticated] and [Loading, Error].
  • Widget: mock AuthBloc; assert spinner on AuthLoading, navigation on AuthAuthenticated.

(All shown in Chapter 26.)

Best Practices

  • Tokens only in secure storage.
  • Convert all auth exceptions to failures in the repository.
  • Always clear local session on logout, even if the network call fails.
  • Centralize refresh policy; attempt once, then logout.

Common Mistakes

  • Storing tokens in SharedPreferences.
  • Navigating from BlocBuilder.
  • Forgetting to persist the refresh token.
  • Infinite refresh loops (retry refresh on a failed refresh).

Chapter 12 — Forms

Focused variant. Forms are mostly ephemeral UI state (the current text, whether a checkbox is ticked). The right tool here is a StatefulWidget with controllers — not a BLoC per field. A BLoC enters only when a form submits to a use case (e.g., login → AuthBloc). We make that boundary explicit.

What & Why

A Form collects and validates user input. Flutter's Form + TextFormField + a GlobalKey<FormState> give you grouped validation: call formKey.currentState!.validate() and every field's validator runs.

When to use / avoid each widget

Widget Use for Avoid when
TextField / TextFormField free text; the latter inside a Form for validation a fixed set of choices
SearchBar search-as-you-type simple text entry
DropdownButton one choice from a long list 2–3 options (use SegmentedButton)
Checkbox independent on/off (terms accepted) mutually exclusive choices
Radio one of a few mutually exclusive many options (use Dropdown)
Switch instant on/off setting choices needing "save"
Slider a value in a range precise numeric entry
SegmentedButton 2–4 mutually exclusive many options
DatePicker/TimePicker dates/times free text dates

How it fits the architecture

Ephemeral input lives in the widget. On submit, dispatch a BLoC Event carrying the values; the BLoC calls a Use Case. Validators are pure functions in core/utils/validators.dart (reusable, testable).

Validation helpers (Core)

// core/utils/validators.dart
class Validators {
  Validators._();
  static String? email(String? v) {
    if (v == null || v.trim().isEmpty) return 'Email is required.';
    if (!RegExp(r'^[\w.\-]+@([\w\-]+\.)+[\w\-]{2,}$').hasMatch(v.trim())) return 'Enter a valid email.';
    return null;
  }
  static String? password(String? v) =>
      (v == null || v.length < 6) ? 'Min 6 characters.' : null;
  static String? required(String? v, {String field = 'This field'}) =>
      (v == null || v.trim().isEmpty) ? '$field is required.' : null;
}

Implementation — a showcase form using every input

class FormsShowcasePage extends StatefulWidget {
  const FormsShowcasePage({super.key});
  @override State<FormsShowcasePage> createState() => _FormsShowcasePageState();
}
class _FormsShowcasePageState extends State<FormsShowcasePage> {
  final _formKey = GlobalKey<FormState>();
  final _name = TextEditingController();
  String? _country;          // dropdown
  bool _accepted = false;    // checkbox
  String _plan = 'free';     // radio / segmented
  bool _notifications = true; // switch
  double _volume = 0.5;      // slider
  DateTime? _date;           // date picker
  TimeOfDay? _time;          // time picker

  @override void dispose() { _name.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Forms')),
      body: Form(
        key: _formKey,
        child: ListView(padding: const EdgeInsets.all(16), children: [
          TextFormField(
            controller: _name,
            decoration: const InputDecoration(labelText: 'Name'),
            validator: (v) => Validators.required(v, field: 'Name'),
          ),
          const SizedBox(height: 12),
          DropdownButtonFormField<String>(
            initialValue: _country,
            decoration: const InputDecoration(labelText: 'Country'),
            items: const [
              DropdownMenuItem(value: 'br', child: Text('Brazil')),
              DropdownMenuItem(value: 'us', child: Text('USA')),
            ],
            onChanged: (v) => setState(() => _country = v),
            validator: (v) => v == null ? 'Select a country' : null,
          ),
          SwitchListTile(
            title: const Text('Notifications'),
            value: _notifications,
            onChanged: (v) => setState(() => _notifications = v),
          ),
          CheckboxListTile(
            title: const Text('I accept the terms'),
            value: _accepted,
            onChanged: (v) => setState(() => _accepted = v ?? false),
          ),
          SegmentedButton<String>(
            segments: const [
              ButtonSegment(value: 'free', label: Text('Free')),
              ButtonSegment(value: 'pro', label: Text('Pro')),
            ],
            selected: {_plan},
            onSelectionChanged: (s) => setState(() => _plan = s.first),
          ),
          Slider(value: _volume, onChanged: (v) => setState(() => _volume = v)),
          Row(children: [
            TextButton(
              onPressed: () async {
                final d = await showDatePicker(
                  context: context, firstDate: DateTime(2000),
                  lastDate: DateTime(2100), initialDate: DateTime.now());
                if (d != null) setState(() => _date = d);
              },
              child: Text(_date == null ? 'Pick date' : '${_date!.toLocal()}'.split(' ')[0]),
            ),
            TextButton(
              onPressed: () async {
                final t = await showTimePicker(context: context, initialTime: TimeOfDay.now());
                if (t != null) setState(() => _time = t);
              },
              child: Text(_time?.format(context) ?? 'Pick time'),
            ),
          ]),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate() && _accepted) {
                // dispatch to a BLoC here in a real submit flow
              }
            },
            child: const Text('Submit'),
          ),
        ]),
      ),
    );
  }
}

Input masks

Use inputFormatters for masking (e.g., digits only). For complex masks add the mask_text_input_formatter package; principle stays the same — formatting is a UI concern, validation a pure-function concern.

TextFormField(
  keyboardType: TextInputType.number,
  inputFormatters: [FilteringTextInputFormatter.digitsOnly],
);

Best Practices / Common Mistakes

  • ✅ Always dispose() controllers (memory leaks otherwise).
  • ✅ Keep validators pure & in Core (reusable + testable).
  • ✅ Use TextFormField inside Form for grouped validation.
  • ❌ Don't build a BLoC for each field; ❌ don't validate by hand when Form does it; ❌ don't forget to gate submit on both validate() and required toggles.

Chapter 13 — Lists

Focused variant. Lists display data that usually comes from a BLoC's Loaded state. The list widgets themselves are pure UI.

What & Why

Lists render collections. Flutter's builders create children lazily (only what's visible), which is essential for long lists' performance.

When to use / avoid

Widget Use for Avoid when
ListView.builder long vertical/horizontal lists tiny fixed sets (plain Column)
GridView.builder grids of cards/photos single-column data
ReorderableListView user-reorderable items static order
ExpansionTile collapsible sections / FAQ always-visible content

How it fits the architecture

The page provides a BLoC, fetches via a use case, and renders the Loaded state with a builder. The list widget knows nothing about networking.

Implementation

// ListView.builder driven by a Loaded state
BlocBuilder<ProductsBloc, ProductsState>(
  builder: (context, state) => switch (state) {
    ProductsLoading() => const AppLoading(),
    ProductsEmpty()   => const AppEmptyView(),
    ProductsError(:final message) => AppErrorView(message: message,
        onRetry: () => context.read<ProductsBloc>().add(const ProductsRequested())),
    ProductsLoaded(:final products) => ListView.builder(
        itemCount: products.length,
        itemBuilder: (_, i) => AppCard(child: Text(products[i].name)),
      ),
    _ => const SizedBox.shrink(),
  },
);
// GridView.builder
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2, childAspectRatio: 3 / 4, crossAxisSpacing: 8, mainAxisSpacing: 8),
  itemCount: items.length,
  itemBuilder: (_, i) => AppCard(child: Center(child: Text(items[i]))),
);
// ReorderableListView — note the immutable-update pattern
class ReorderDemo extends StatefulWidget {
  const ReorderDemo({super.key});
  @override State<ReorderDemo> createState() => _ReorderDemoState();
}
class _ReorderDemoState extends State<ReorderDemo> {
  List<String> _items = List.generate(5, (i) => 'Item ${i + 1}');
  @override
  Widget build(BuildContext c) => ReorderableListView(
    onReorder: (oldI, newI) => setState(() {
      if (newI > oldI) newI -= 1;
      final moved = _items.removeAt(oldI);
      _items.insert(newI, moved);
    }),
    children: [for (final it in _items) ListTile(key: ValueKey(it), title: Text(it))],
  );
}
// ExpansionTile
ExpansionTile(
  title: const Text('Section'),
  children: const [ListTile(title: Text('Detail A')), ListTile(title: Text('Detail B'))],
);

Best Practices / Common Mistakes

  • ✅ Use .builder for anything beyond a handful of items.
  • ✅ Give reorderable/keyed items stable Keys.
  • ❌ Don't build huge lists with a plain Column (renders everything → jank).
  • ❌ Don't mutate the BLoC's list in place; emit a new list (Equatable).

Chapter 14 — Pagination

Full-ish variant — pagination genuinely needs BLoC state design.

What & Why

Pagination loads data in pages instead of all at once: infinite scroll (load more as you reach the bottom), pull-to-refresh (reload page 1), and lazy loading (fetch on demand). It keeps memory and network use bounded and the first paint fast.

When to use / avoid

Use for any list that can grow large (feeds, search results, catalogs). Avoid for small, fixed lists — added complexity for no gain.

BLoC design — the key is a paginated state

The state must remember accumulated items, the current page, whether more pages exist, and whether a "load more" is in flight (so we don't fire duplicate requests).

// state
class FeedState extends Equatable {
  final List<Post> items;
  final int page;
  final bool hasMore;
  final bool isLoadingMore;
  final bool isInitialLoading;
  final String? error;
  const FeedState({
    this.items = const [], this.page = 0, this.hasMore = true,
    this.isLoadingMore = false, this.isInitialLoading = false, this.error,
  });
  FeedState copyWith({List<Post>? items, int? page, bool? hasMore,
      bool? isLoadingMore, bool? isInitialLoading, String? error}) => FeedState(
    items: items ?? this.items, page: page ?? this.page,
    hasMore: hasMore ?? this.hasMore, isLoadingMore: isLoadingMore ?? this.isLoadingMore,
    isInitialLoading: isInitialLoading ?? this.isInitialLoading, error: error,
  );
  @override List<Object?> get props => [items, page, hasMore, isLoadingMore, isInitialLoading, error];
}
// events
sealed class FeedEvent {}
class FeedStarted extends FeedEvent {}
class FeedLoadMore extends FeedEvent {}
class FeedRefreshed extends FeedEvent {}
// bloc (guards against duplicate loads)
class FeedBloc extends Bloc<FeedEvent, FeedState> {
  final GetFeed getFeed;
  FeedBloc(this.getFeed) : super(const FeedState()) {
    on<FeedStarted>((e, emit) async {
      emit(state.copyWith(isInitialLoading: true, error: null));
      await _fetch(1, emit, replace: true);
    });
    on<FeedLoadMore>((e, emit) async {
      if (state.isLoadingMore || !state.hasMore) return; // guard
      emit(state.copyWith(isLoadingMore: true));
      await _fetch(state.page + 1, emit);
    });
    on<FeedRefreshed>((e, emit) async => _fetch(1, emit, replace: true));
  }
  Future<void> _fetch(int page, Emitter<FeedState> emit, {bool replace = false}) async {
    final r = await getFeed(FeedParams(page: page));
    r.fold(
      (f) => emit(state.copyWith(isInitialLoading: false, isLoadingMore: false, error: f.message)),
      (p) => emit(state.copyWith(
        items: replace ? p.items : [...state.items, ...p.items],
        page: page, hasMore: p.hasMore,
        isInitialLoading: false, isLoadingMore: false, error: null,
      )),
    );
  }
}

Implementation — infinite scroll + pull-to-refresh

class FeedPage extends StatefulWidget {
  const FeedPage({super.key});
  @override State<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
  final _scroll = ScrollController();
  @override void initState() {
    super.initState();
    _scroll.addListener(() {
      if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 300) {
        context.read<FeedBloc>().add(FeedLoadMore()); // near bottom -> load more
      }
    });
  }
  @override void dispose() { _scroll.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) => BlocBuilder<FeedBloc, FeedState>(
    builder: (context, s) {
      if (s.isInitialLoading) return const AppLoading();
      return RefreshIndicator(
        onRefresh: () async => context.read<FeedBloc>().add(FeedRefreshed()),
        child: ListView.builder(
          controller: _scroll,
          itemCount: s.items.length + (s.hasMore ? 1 : 0),
          itemBuilder: (_, i) {
            if (i >= s.items.length) {
              return const Padding(padding: EdgeInsets.all(16), child: AppLoading());
            }
            return ListTile(title: Text(s.items[i].title));
          },
        ),
      );
    },
  );
}

Testing / Best Practices / Mistakes

  • Test: FeedLoadMore while isLoadingMore emits nothing (guard works); appends items; stops at hasMore: false.
  • ✅ Guard against duplicate loads; ✅ trigger before the exact bottom (prefetch).
  • ❌ Don't re-fetch page 1 on every rebuild; ❌ don't lose accumulated items on "load more."

Chapter 15 — Search

Full-ish variant — search needs debounce, a perfect job for a Bloc with an event transformer.

What & Why

Search filters/finds data as the user types. Debounce waits until typing pauses (e.g., 350 ms) before querying, so we don't fire a request per keystroke. Filters and sorting refine results.

Why a Bloc (not Cubit) here

Bloc supports event transformers (bloc_concurrency): debounce + restartable (cancel the previous in-flight search when a new query arrives). This is the textbook case where Bloc beats Cubit.

Implementation

import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:rxdart/rxdart.dart';

sealed class SearchEvent {}
class QueryChanged extends SearchEvent { final String q; QueryChanged(this.q); }
class SortChanged extends SearchEvent { final SortBy by; SortChanged(this.by); }
enum SortBy { relevance, nameAsc, priceAsc }

EventTransformer<E> debounce<E>(Duration d) =>
    (events, mapper) => events.debounceTime(d).switchMap(mapper);

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  final SearchProducts search;
  SortBy _sort = SortBy.relevance;
  SearchBloc(this.search) : super(const SearchState.initial()) {
    on<QueryChanged>(_onQuery, transformer: debounce(const Duration(milliseconds: 350)));
    on<SortChanged>((e, emit) { _sort = e.by; add(QueryChanged(state.query)); });
  }
  Future<void> _onQuery(QueryChanged e, Emitter<SearchState> emit) async {
    if (e.q.trim().isEmpty) return emit(const SearchState.initial());
    emit(SearchState.loading(e.q));
    final r = await search(SearchParams(query: e.q));
    r.fold(
      (f) => emit(SearchState.error(e.q, f.message)),
      (items) {
        final sorted = _applySort(items, _sort);
        emit(sorted.isEmpty ? SearchState.empty(e.q) : SearchState.loaded(e.q, sorted));
      },
    );
  }
  List<Product> _applySort(List<Product> xs, SortBy by) {
    final list = [...xs];
    switch (by) {
      case SortBy.nameAsc: list.sort((a, b) => a.name.compareTo(b.name));
      case SortBy.priceAsc: list.sort((a, b) => a.price.compareTo(b.price));
      case SortBy.relevance: break;
    }
    return list;
  }
}
// UI
TextField(
  decoration: const InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Search...'),
  onChanged: (q) => context.read<SearchBloc>().add(QueryChanged(q)),
);

If you prefer no extra packages, debounce manually with a Timer that you cancel/restart in the widget before adding the event — but the transformer approach is cleaner and cancels in-flight requests too.

Best Practices / Mistakes

  • ✅ Debounce; ✅ cancel stale requests (switchMap/restartable); ✅ handle empty query → initial state.
  • ❌ Don't query on every keystroke; ❌ don't show old results for a new query (race conditions).

Chapter 16 — Dialogs

Focused variant. Dialogs are transient UI; they often return a value the caller acts on. Keep decision logic out of the dialog — the dialog asks, the caller (or a BLoC) decides.

What & Why

A dialog interrupts to inform or ask. AlertDialog (message + actions), bottom sheets (slide-up panels), modal dialogs (custom blocking), and confirmation dialogs (yes/no) cover most needs.

When to use / avoid

Use for short, focused interactions requiring acknowledgement or a quick choice. Avoid for complex multi-step flows (use a route/page) and for non-critical info (use a SnackBar).

Implementation

// Confirmation dialog returning bool
Future<bool> confirm(BuildContext context, String message) async {
  final result = await showDialog<bool>(
    context: context,
    builder: (ctx) => AlertDialog(
      title: const Text('Please confirm'),
      content: Text(message),
      actions: [
        TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
        FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Confirm')),
      ],
    ),
  );
  return result ?? false;
}

// Usage — caller decides what to do with the answer:
onPressed: () async {
  final ok = await confirm(context, 'Delete this item?');
  if (!context.mounted) return;          // guard after await
  if (ok) context.read<ItemsBloc>().add(ItemDeleted(id));
}
// Bottom sheet
showModalBottomSheet(
  context: context,
  showDragHandle: true,
  builder: (_) => Padding(
    padding: const EdgeInsets.all(16),
    child: Column(mainAxisSize: MainAxisSize.min, children: const [
      ListTile(leading: Icon(Icons.share), title: Text('Share')),
      ListTile(leading: Icon(Icons.edit), title: Text('Edit')),
    ]),
  ),
);

Best Practices / Mistakes

  • ✅ Return values via Navigator.pop(context, value); ✅ context.mounted check after awaiting a dialog.
  • ❌ Don't bury business logic inside the dialog; ❌ don't use dialogs for long forms.

Chapter 17 — Animations

Focused variant. Animations are pure UI polish; controllers are ephemeral stateStatefulWidget with TickerProvider is correct, not BLoC.

What & Why

Animations communicate change and delight. Implicit animations (AnimatedContainer, AnimatedOpacity, AnimatedSwitcher) interpolate automatically when a property changes. Explicit animations (AnimationController) give frame-level control. Hero animates a shared element between routes. Page transitions animate route changes.

When to use / avoid

Use to clarify state changes and spatial relationships. Avoid gratuitous animation that delays the user or animates on every rebuild unintentionally.

Implementation

// Implicit — toggling a bool re-animates automatically
class ImplicitDemo extends StatefulWidget {
  const ImplicitDemo({super.key});
  @override State<ImplicitDemo> createState() => _ImplicitDemoState();
}
class _ImplicitDemoState extends State<ImplicitDemo> {
  bool _big = false;
  @override
  Widget build(BuildContext c) => GestureDetector(
    onTap: () => setState(() => _big = !_big), // ephemeral UI state -> setState OK
    child: Column(children: [
      AnimatedContainer(
        duration: const Duration(milliseconds: 300), curve: Curves.easeInOut,
        width: _big ? 200 : 100, height: _big ? 200 : 100, color: Colors.indigo,
      ),
      AnimatedOpacity(
        duration: const Duration(milliseconds: 300),
        opacity: _big ? 1 : 0.3, child: const Text('Fades'),
      ),
      AnimatedSwitcher(
        duration: const Duration(milliseconds: 300),
        child: Text('$_big', key: ValueKey(_big)),
      ),
    ]),
  );
}
// Hero — same tag on both screens animates the shared element across the route
// Screen A:
Hero(tag: 'avatar-$id', child: CircleAvatar(backgroundImage: NetworkImage(url)));
// Screen B (detail):
Hero(tag: 'avatar-$id', child: CircleAvatar(radius: 64, backgroundImage: NetworkImage(url)));
// Custom page transition with GoRouter
GoRoute(
  path: '/detail',
  pageBuilder: (context, state) => CustomTransitionPage(
    key: state.pageKey,
    child: const DetailPage(),
    transitionsBuilder: (_, anim, __, child) =>
        FadeTransition(opacity: anim, child: child),
  ),
);

Best Practices / Mistakes

  • ✅ Always dispose() controllers; ✅ keep durations short (200–400 ms); ✅ unique Hero tags.
  • ❌ Don't manage animation state in BLoC; ❌ don't animate large subtrees needlessly (jank).

Chapter 18 — Layouts

Focused variant + a deep dive into Flutter's constraint model, the single most important layout concept.

The golden rule of Flutter layout

Constraints go down. Sizes go up. Parent sets position.

A parent passes constraints (min/max width & height) down to a child. The child chooses its size within those constraints and reports it up. The parent then positions the child. Most layout confusion ("why is my widget full-width?", "unbounded height" errors) comes from not thinking in constraints.

graph TD
    P[Parent] -->|constraints down| C[Child]
    C -->|size up| P
    P -->|positions child| C
Loading

A classic error: putting a ListView (wants infinite height) inside a Column (offers unbounded height) → "RenderFlex overflow / unbounded height." Fix: wrap the ListView in Expanded (Column gives it the remaining bounded space).

The widgets

Widget Role
Row / Column lay children horizontally / vertically
Stack overlap children (z-order)
Wrap flow children to next line when out of space
Expanded child fills remaining space along the main axis (flex)
Flexible like Expanded but may be smaller than its share
ListView / GridView scrollable lists/grids (lazy)
CustomScrollView + Slivers advanced scroll effects (collapsing headers)
NestedScrollView coordinate outer + inner scrollables (header + tabs)
TabBar / TabBarView tabbed navigation

Implementation highlights

// Row with flex
Row(children: const [
  Expanded(flex: 2, child: ColoredBox(color: Colors.red, child: SizedBox(height: 40))),
  Expanded(flex: 1, child: ColoredBox(color: Colors.blue, child: SizedBox(height: 40))),
]);

// Stack
Stack(children: [
  Container(color: Colors.amber, height: 150),
  const Positioned(bottom: 8, right: 8, child: Icon(Icons.star)),
]);

// Wrap (chips flowing to new lines)
Wrap(spacing: 8, runSpacing: 8, children: const [Chip(label: Text('a')), Chip(label: Text('b'))]);

// Column + Expanded(ListView) — the overflow fix
Column(children: [
  const Text('Header'),
  Expanded(child: ListView(children: const [ListTile(title: Text('1'))])),
]);
// Slivers: a collapsing app bar + list in one CustomScrollView
CustomScrollView(slivers: [
  const SliverAppBar(expandedHeight: 180, pinned: true,
    flexibleSpace: FlexibleSpaceBar(title: Text('Slivers'))),
  SliverList(delegate: SliverChildBuilderDelegate(
    (_, i) => ListTile(title: Text('Row $i')), childCount: 30)),
]);
// Tabs
DefaultTabController(
  length: 2,
  child: Scaffold(
    appBar: AppBar(bottom: const TabBar(tabs: [Tab(text: 'One'), Tab(text: 'Two')])),
    body: const TabBarView(children: [Center(child: Text('1')), Center(child: Text('2'))]),
  ),
);

Best Practices / Mistakes

  • ✅ Think in constraints; ✅ use Expanded/Flexible inside Row/Column for sizing; ✅ use Slivers for fancy scroll effects.
  • ❌ Don't nest scrollables of the same axis without NestedScrollView; ❌ don't put unbounded-height widgets in unbounded parents; ❌ don't hardcode pixel sizes for responsive layouts (use LayoutBuilder/MediaQuery).

Chapter 19 — Media

Focused variant. Media access (camera, gallery) uses platform plugins; treat them as a Core service behind an interface so features depend on an abstraction (and tests can fake it).

What & Why

Media features: pick/capture images (image_picker), preview them, browse galleries, swipe carousels, and view PDFs (pdfx/syncfusion_flutter_pdfviewer). They need runtime permissions (camera, photos).

How it fits the architecture

Wrap the plugin in a MediaService (Core) with an interface. A feature's data source/use case depends on the interface, not the plugin — keeping plugins out of business code and enabling tests.

// core/services/media_service.dart
import 'package:image_picker/image_picker.dart';
abstract class MediaService {
  Future<String?> pickImageFromGallery();
  Future<String?> captureImageFromCamera();
}
class MediaServiceImpl implements MediaService {
  final ImagePicker _picker;
  MediaServiceImpl(this._picker);
  @override Future<String?> pickImageFromGallery() async =>
      (await _picker.pickImage(source: ImageSource.gallery))?.path;
  @override Future<String?> captureImageFromCamera() async =>
      (await _picker.pickImage(source: ImageSource.camera))?.path;
}

Implementation — preview & carousel

import 'dart:io';
// Preview a picked file
Image.file(File(path), height: 200, fit: BoxFit.cover);

// Carousel using PageView
SizedBox(
  height: 200,
  child: PageView(children: [
    for (final url in imageUrls) Image.network(url, fit: BoxFit.cover),
  ]),
);

Best Practices / Mistakes

  • ✅ Request permissions and handle denial gracefully; ✅ wrap plugins behind interfaces; ✅ show loading/placeholder for network images (loadingBuilder, errorBuilder).
  • ❌ Don't call plugins directly from widgets/BLoCs; ❌ don't assume permission is granted.

Chapter 20 — File Management

Full-ish variant — uploads/downloads are real Data-layer operations with use cases.

What & Why

Upload (send files via multipart), download (fetch bytes and save), and track progress. These cross the network boundary, so they're proper Data-layer features with use cases and failures.

Implementation — upload via the multipart client

// data source uses HttpClient.multipart (Chapter 7)
class FileRemoteDataSource {
  final HttpClient client;
  FileRemoteDataSource(this.client);
  Future<String> upload(String path) async {
    final data = await client.multipart('/files', filePath: path, field: 'file');
    return (data as Map<String, dynamic>)['url'] as String;
  }
}
// use case
class UploadFile implements UseCase<String, UploadParams> {
  final FileRepository repo; const UploadFile(this.repo);
  @override Future<Result<Failure, String>> call(UploadParams p) => repo.upload(p.path);
}

Download (bytes → disk)

import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

Future<File> download(String url, String filename) async {
  final res = await http.get(Uri.parse(url));
  final dir = await getApplicationDocumentsDirectory();
  final file = File('${dir.path}/$filename');
  return file.writeAsBytes(res.bodyBytes);
}

Best Practices / Mistakes

  • ✅ Stream large files; ✅ show progress; ✅ validate type/size before upload; ✅ handle storage permissions.
  • ❌ Don't load huge files fully into memory; ❌ don't block the UI thread on big writes.

Chapter 21 — Local Storage

Full-ish variant — choosing the right storage is an architectural decision.

What & Why

Three persistence needs with three tools:

Need Tool Encrypted? Example
Secrets flutter_secure_storage ✅ OS Keychain/Keystore auth tokens, passwords
Simple non-secret settings SharedPreferences ❌ plain text theme mode, last tab
Structured / large data cache a DB (Hive/Isar/sqflite) ❌ (encrypt if needed) offline list cache, favorites

The rule, restated: secrets → secure storage only; never SharedPreferences (plain text). Non-secret prefs → SharedPreferences is fine. Larger structured data → a real local DB.

When to use which

  • Secure storage: anything that, if leaked, harms the user (tokens, PII).
  • SharedPreferences: tiny, non-sensitive key-values where loss is harmless.
  • DB/cache: lists you want offline, favorites, anything queryable or large.

Implementation — a cache service + favorites persistence

// core/storage/cache_service.dart  (SharedPreferences-based, non-secret)
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class CacheService {
  final SharedPreferences _prefs;
  CacheService(this._prefs);
  Future<void> setString(String k, String v) => _prefs.setString(k, v);
  String? getString(String k) => _prefs.getString(k);
  Future<void> setJson(String k, Map<String, dynamic> v) => _prefs.setString(k, jsonEncode(v));
  Map<String, dynamic>? getJson(String k) {
    final s = _prefs.getString(k);
    return s == null ? null : jsonDecode(s) as Map<String, dynamic>;
  }
  Future<void> setBool(String k, bool v) => _prefs.setBool(k, v);
  bool getBool(String k, {bool fallback = false}) => _prefs.getBool(k) ?? fallback;
  Future<void> setStringList(String k, List<String> v) => _prefs.setStringList(k, v);
  List<String> getStringList(String k) => _prefs.getStringList(k) ?? const [];
}
// favorites persistence (non-secret) via a local data source
class FavoritesLocalDataSource {
  final CacheService cache;
  FavoritesLocalDataSource(this.cache);
  static const _key = 'favorite_ids';
  List<String> get all => cache.getStringList(_key);
  Future<void> toggle(String id) async {
    final set = all.toSet();
    set.contains(id) ? set.remove(id) : set.add(id);
    await cache.setStringList(_key, set.toList());
  }
}

Offline cache pattern (repository): try remote; on success, write to cache and return; on NetworkFailure, read cache and return it (optionally flagged stale).

Future<Result<Failure, List<Product>>> getProducts() async {
  try {
    final remote = await remoteDataSource.getProducts();
    await cache.setJson('products', {'items': remote.map((e) => e.toJson()).toList()});
    return Ok(remote);
  } on NetworkException {
    final cached = cache.getJson('products');
    if (cached != null) {
      final items = (cached['items'] as List)
          .map((j) => ProductModel.fromJson(j)).toList();
      return Ok(items);
    }
    return const Failed(NetworkFailure());
  } catch (_) { return const Failed(UnknownFailure()); }
}

Best Practices / Mistakes

  • ✅ Secrets in secure storage only; ✅ wrap each store behind a Core service; ✅ version your cache schema.
  • ❌ Tokens in SharedPreferences; ❌ caching sensitive data unencrypted; ❌ treating cache as the source of truth without expiry.

Chapter 22 — Charts

Focused variant. Charts visualize data from a Loaded state. Use a package like fl_chart.

What & Why

Charts turn numbers into insight: line (trends over time), bar (comparisons), pie (parts of a whole).

How it fits

The BLoC fetches/derives the data (Domain entities → simple chart points in the presentation layer). The chart widget is pure UI.

Implementation (fl_chart sketches)

import 'package:fl_chart/fl_chart.dart';

// Line
LineChart(LineChartData(lineBarsData: [
  LineChartBarData(spots: const [FlSpot(0,1), FlSpot(1,3), FlSpot(2,2), FlSpot(3,5)]),
]));

// Bar
BarChart(BarChartData(barGroups: [
  for (var i = 0; i < values.length; i++)
    BarChartGroupData(x: i, barRods: [BarChartRodData(toY: values[i])]),
]));

// Pie
PieChart(PieChartData(sections: [
  PieChartSectionData(value: 40, title: 'A'),
  PieChartSectionData(value: 60, title: 'B'),
]));

Best Practices / Mistakes

  • ✅ Map domain data to chart points in the presentation layer; ✅ handle empty/loading; ✅ label axes.
  • ❌ Don't put chart math in widgets if it's business logic (move to a use case); ❌ don't render charts with no data guard.

Chapter 23 — Calendar

Focused variant. Use table_calendar; events come from a BLoC.

What & Why

Calendar view (month/week), events (markers on days), and an agenda (list of a day's events). Useful for bookings, schedules, planners.

How it fits

A CalendarBloc loads events (use case → repository → API). The widget renders the month and, on day selection, shows that day's agenda from state.

Implementation (sketch)

import 'package:table_calendar/table_calendar.dart';

TableCalendar(
  firstDay: DateTime.utc(2020), lastDay: DateTime.utc(2030),
  focusedDay: focusedDay,
  selectedDayPredicate: (d) => isSameDay(d, selectedDay),
  eventLoader: (day) => eventsByDay[day] ?? const [],   // markers
  onDaySelected: (selected, focused) =>
      context.read<CalendarBloc>().add(DaySelected(selected)),
);
// Agenda: a ListView of the selected day's events from CalendarState.

Best Practices / Mistakes

  • ✅ Normalize dates (strip time) when keying events; ✅ load events via use cases.
  • ❌ Don't store event-fetching logic in the widget; ❌ don't compare DateTimes including time when matching days.

Chapter 24 — Maps

Focused variant. Maps + location are platform services behind interfaces.

What & Why

Show a map (google_maps_flutter), the user's geolocation (geolocator), markers (points of interest), and routes (polylines). Requires location permissions and platform API keys.

How it fits

A LocationService (Core) wraps geolocator. A MapBloc requests location (via a use case) and holds markers/route state. The map widget renders state.

// core/services/location_service.dart
abstract class LocationService { Future<({double lat, double lng})> current(); }
import 'package:google_maps_flutter/google_maps_flutter.dart';
GoogleMap(
  initialCameraPosition: CameraPosition(target: LatLng(lat, lng), zoom: 14),
  markers: {Marker(markerId: const MarkerId('me'), position: LatLng(lat, lng))},
  polylines: {Polyline(polylineId: const PolylineId('route'), points: routePoints)},
);

Best Practices / Mistakes

  • ✅ Request/handle location permission; ✅ wrap geolocation behind an interface; ✅ keep API keys in platform config, not source.
  • ❌ Don't block UI waiting for a GPS fix; ❌ don't hardcode keys in committed code.

Chapter 25 — Shared Components

What is it?

Reusable, dumb UI widgets living in core/widgets/: buttons, text fields, cards, loading/error/empty views. They take inputs and render — no business logic, no BLoC, no networking.

Why does it exist? / Why in Core?

Because consistency and reuse are app-wide concerns. A single AppButton guarantees every button looks and behaves the same and fixes (e.g., a disabled-while-loading rule) happen once. They're in Core precisely because every feature uses them unchanged — the definition of a Core citizen.

When to use / avoid

Use for any UI element repeated across features. Avoid putting feature-specific composites here (a ProductCard that knows about products belongs in the products feature, even if it reuses AppCard).

Implementation

// core/widgets/app_button.dart
import 'package:flutter/material.dart';
class AppButton extends StatelessWidget {
  final String label; final VoidCallback? onPressed; final bool isLoading;
  const AppButton({super.key, required this.label, required this.onPressed, this.isLoading = false});
  @override
  Widget build(BuildContext c) => SizedBox(
    width: double.infinity, height: 50,
    child: ElevatedButton(
      onPressed: isLoading ? null : onPressed,  // disabled while loading
      child: isLoading
        ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2.5))
        : Text(label),
    ),
  );
}
// core/widgets/app_text_field.dart
import 'package:flutter/material.dart';
class AppTextField extends StatelessWidget {
  final TextEditingController controller; final String label;
  final bool obscureText; final TextInputType keyboardType;
  final String? Function(String?)? validator;
  const AppTextField({super.key, required this.controller, required this.label,
    this.obscureText = false, this.keyboardType = TextInputType.text, this.validator});
  @override
  Widget build(BuildContext c) => TextFormField(
    controller: controller, obscureText: obscureText, keyboardType: keyboardType,
    validator: validator, decoration: InputDecoration(labelText: label),
  );
}
// core/widgets/app_card.dart
import 'package:flutter/material.dart';
class AppCard extends StatelessWidget {
  final Widget child; final VoidCallback? onTap;
  const AppCard({super.key, required this.child, this.onTap});
  @override
  Widget build(BuildContext c) => Card(
    elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    child: InkWell(onTap: onTap, borderRadius: BorderRadius.circular(12),
      child: Padding(padding: const EdgeInsets.all(16), child: child)),
  );
}

(AppLoading, AppErrorView, AppEmptyView are in Chapter 10.)

Best Practices / Mistakes

  • ✅ Keep them dumb and stateless; ✅ parameterize via constructor; ✅ one consistent look via theme.
  • ❌ No BLoC/network inside; ❌ no feature knowledge; ❌ don't duplicate them per feature.

Chapter 26 — Testing

What is it?

Testing is writing code that verifies your code. The whole architecture exists partly to make this easy: because dependencies are injected and layers talk to abstractions, every unit is testable in isolation with fakes.

Why does it exist?

To ship with confidence, refactor safely, and document behavior. A good suite catches regressions before users do.

The testing pyramid (strategy)

graph TD
    W[Few Widget/Integration tests<br/>slow, high-level] --> B[Some BLoC tests]
    B --> U[Many Unit tests<br/>fast: use cases, repos, data sources]
    style U fill:#2d6a4f,color:#fff
Loading

Lots of fast unit tests at the base; fewer, slower widget/integration tests on top. Test behavior, not implementation details.

Tools

  • flutter_test — assertions, testWidgets.
  • mocktail — mocks without code-gen.
  • bloc_testblocTest for asserting emitted states.

Unit test — Use Case

class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
  late LoginUseCase useCase; late MockAuthRepository repo;
  setUp(() { repo = MockAuthRepository(); useCase = LoginUseCase(repo); });
  const user = User(id: '1', name: 'Ada', email: 'a@x.com');

  test('forwards to repository and returns user', () async {
    when(() => repo.login(email: any(named: 'email'), password: any(named: 'password')))
        .thenAnswer((_) async => const Ok(user));
    final r = await useCase(const LoginParams(email: 'a@x.com', password: 'pw'));
    expect(r, const Ok<Failure, User>(user));
    verify(() => repo.login(email: 'a@x.com', password: 'pw')).called(1);
  });
}

Repository test — Exception → Failure & token persistence

class MockDS extends Mock implements AuthRemoteDataSource {}
class MockStorage extends Mock implements SecureStorageService {}
void main() {
  late AuthRepositoryImpl repo; late MockDS ds; late MockStorage storage;
  setUp(() {
    ds = MockDS(); storage = MockStorage();
    repo = AuthRepositoryImpl(remoteDataSource: ds, storage: storage);
  });

  test('saves tokens and returns Ok(User) on success', () async {
    const user = UserModel(id: '1', name: 'Ada', email: 'a@x.com');
    when(() => ds.login(any(), any()))
        .thenAnswer((_) async => (user: user, access: 'a', refresh: 'r'));
    when(() => storage.saveAccessToken(any())).thenAnswer((_) async {});
    when(() => storage.saveRefreshToken(any())).thenAnswer((_) async {});
    final r = await repo.login(email: 'a@x.com', password: 'pw');
    expect(r, isA<Ok>());
    verify(() => storage.saveAccessToken('a')).called(1);
  });

  test('maps AuthException to AuthFailure', () async {
    when(() => ds.login(any(), any())).thenThrow(const AuthException('bad'));
    final r = await repo.login(email: 'a', password: 'b');
    expect(r, const Failed<Failure, User>(AuthFailure('bad')));
  });
}

Data source test — status handling

class MockHttp extends Mock implements HttpClient {}
void main() {
  late AuthRemoteDataSourceImpl ds; late MockHttp http;
  setUp(() { http = MockHttp(); ds = AuthRemoteDataSourceImpl(client: http); });

  test('parses login payload', () async {
    when(() => http.post(any(), body: any(named: 'body'))).thenAnswer((_) async => {
      'user': {'id': '1', 'name': 'Ada', 'email': 'a@x.com'},
      'access_token': 'a', 'refresh_token': 'r',
    });
    final r = await ds.login('a@x.com', 'pw');
    expect(r.access, 'a'); expect(r.user.id, '1');
  });
}

BLoC test

class MockLogin extends Mock implements LoginUseCase {}
void main() {
  late MockLogin login;
  setUp(() { login = MockLogin(); registerFallbackValue(const LoginParams(email:'',password:'')); });
  const user = User(id: '1', name: 'Ada', email: 'a@x.com');

  AuthBloc build() => AuthBloc(loginUseCase: login,
      registerUseCase: MockRegister(), logoutUseCase: MockLogout(),
      getCurrentUserUseCase: MockGetUser());

  blocTest<AuthBloc, AuthState>('emits [Loading, Authenticated] on success',
    build: () { when(() => login(any())).thenAnswer((_) async => const Ok(user)); return build(); },
    act: (b) => b.add(const LoginRequested(email: 'a@x.com', password: 'pw')),
    expect: () => const [AuthLoading(), AuthAuthenticated(user)]);

  blocTest<AuthBloc, AuthState>('emits [Loading, Error] on failure',
    build: () { when(() => login(any())).thenAnswer((_) async => const Failed(AuthFailure('nope'))); return build(); },
    act: (b) => b.add(const LoginRequested(email: 'a', password: 'b')),
    expect: () => const [AuthLoading(), AuthError('nope')]);
}

Widget test

class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {}
void main() {
  testWidgets('shows spinner during AuthLoading', (tester) async {
    final bloc = MockAuthBloc();
    whenListen(bloc, const Stream<AuthState>.empty(), initialState: const AuthLoading());
    await tester.pumpWidget(MaterialApp(home: BlocProvider<AuthBloc>.value(
      value: bloc, child: const Scaffold(body: LoginForm()))));
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });
}

Best Practices / Mistakes

  • ✅ Name tests "emits X when Y"; ✅ one logical assertion; ✅ mock only direct collaborators; ✅ keep Domain tests pure Dart.
  • ❌ Don't test framework internals; ❌ don't hit real network/storage in unit tests; ❌ don't assert on implementation details that legitimate refactors would break.

Chapter 27 — Best Practices

SOLID, in plain terms (and where each appears here)

  • S — Single Responsibility: one reason to change per class. HttpStatusHandler only maps codes; SecureStorageService only stores; a BLoC manages one feature's state.
  • O — Open/Closed: extend without modifying. Add a new Failure subtype or UseCase without editing existing ones.
  • L — Liskov Substitution: UserModel substitutes for User anywhere.
  • I — Interface Segregation: small focused contracts (AuthRepository) not one giant interface.
  • D — Dependency Inversion: depend on AuthRepository (abstraction), DI injects AuthRepositoryImpl.

Naming conventions

  • Files snake_case.dart; classes PascalCase; entities are nouns (User), use cases verbs (LoginUseCase).
  • Abstractions plain (AuthRepository); implementations …Impl.
  • BLoC trio together: x_bloc.dart, x_event.dart, x_state.dart (part of).
  • Constants in classes with private constructors (AppRoutes._()).

Scalability, maintainability, reusability

  • Scalability: new feature = one folder (3 layers) + one _initX() in DI. Nothing else changes.
  • Maintainability: isolated layers contain change. Swap httpdio: only HttpClient. Redesign login: only presentation/.
  • Reusability: shared UI/utils in Core; never copy-paste across features.

Folder organization (the test)

"If deleting a feature folder leaves the app compiling (minus that feature), the boundaries are correct."

What to avoid (global)

  • Business logic in widgets or Utils.
  • UI importing http/json/data sources.
  • Domain importing Flutter/http/json.
  • God-BLoCs and junk-drawer Utils.
  • Secrets in SharedPreferences.
  • Raw Navigator when using GoRouter.
  • setState/Provider for business state (ephemeral UI state with StatefulWidget is fine).

Chapter 28 — The Complete Application Flow

Here is the entire request lifecycle, layer by layer, exactly as it happens for any data-fetching action (login shown).

sequenceDiagram
    actor U as User
    participant UI as Widget
    participant B as BLoC
    participant UC as Use Case
    participant R as Repository Impl
    participant DS as Data Source
    participant H as HttpClient
    participant SH as StatusHandler
    participant API as REST API
    participant S as Secure Storage

    U->>UI: 1. interaction (tap)
    UI->>B: 2. add(Event)
    B->>B: 3. emit(Loading)
    B->>UC: 4. call(Params)
    UC->>R: 5. repository method
    R->>DS: 6. data source method
    DS->>H: 7. HTTP request
    H->>API: 8. request (headers, timeout, retry)
    API-->>H: 9. response (status + body)
    H->>SH: handle(status, body)
    alt success (2xx)
        SH-->>H: decoded body
        H-->>DS: JSON
        DS-->>R: Model
        R->>S: persist token (if auth)
        R-->>UC: 12a. Ok(Entity)  (Model→Entity)
        UC-->>B: Ok(Entity)
        B->>B: 12b. emit(Success/Empty)
    else failure (4xx/5xx/timeout)
        SH-->>H: 10. throw Exception
        H-->>DS: propagates
        DS-->>R: propagates
        R->>R: 11. catch → return Failure (Exception→Failure)
        R-->>UC: Failed(Failure)
        UC-->>B: Failed(Failure)
        B->>B: emit(Error)
    end
    B-->>UI: 13. state changes
    UI->>UI: rebuild (Builder) / react (Listener)
Loading

In words: (1) user interacts → (2) UI adds an Event → (3) BLoC emits Loading → (4) BLoC calls a Use Case → (5) Use Case calls the Repository → (6) Repository calls a Data Source → (7–8) Data Source makes the HTTP request → (9) response arrives → (10) on error the StatusHandler throws an Exception → (11) the Repository converts it to a Failure → (12) data (mapped Model→Entity) or Failure flows back and the BLoC emits a new State → (13) the UI updates (BlocBuilder rebuilds, BlocListener navigates/snackbars).

Every arrow respects the Dependency Rule: each layer only knows the abstraction directly beneath it. That locality is what makes the whole system testable, swappable, and scalable.

Putting it all together — main.dart and pubspec.yaml

// lib/main.dart
import 'package:flutter/material.dart';
import 'core/dependency_injection/injection_container.dart' as di;
import 'core/router/app_router.dart';
import 'core/themes/app_theme.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized(); // needed before plugins
  await di.init();                            // register all dependencies
  runApp(const ShowcaseApp());
}

class ShowcaseApp extends StatelessWidget {
  const ShowcaseApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp.router(
    title: 'Flutter Architecture Showcase',
    theme: AppTheme.light,
    routerConfig: AppRouter.router,           // GoRouter owns all navigation
    debugShowCheckedModeBanner: false,
  );
}
# pubspec.yaml (key parts)
dependencies:
  flutter: { sdk: flutter }
  flutter_bloc: ^8.1.6
  bloc_concurrency: ^0.2.5      # debounce/restartable for search
  rxdart: ^0.28.0               # stream operators for transformers
  equatable: ^2.0.5
  get_it: ^7.7.0
  http: ^1.2.2
  json_annotation: ^4.9.0
  flutter_secure_storage: ^9.2.2
  shared_preferences: ^2.3.2
  go_router: ^14.2.7
  # optional showcase plugins:
  image_picker: ^1.1.2
  path_provider: ^2.1.4
  fl_chart: ^0.69.0
  table_calendar: ^3.1.2
  google_maps_flutter: ^2.9.0
  geolocator: ^13.0.1

dev_dependencies:
  flutter_test: { sdk: flutter }
  build_runner: ^2.4.12
  json_serializable: ^6.8.0
  bloc_test: ^9.1.7
  mocktail: ^1.0.4

Startup order matters: bindings → DI init → runApp → router, because the router's guard resolves SecureStorageService from DI.


Chapter 29 — Glossary

Term Meaning
Widget Immutable description of part of the UI.
Declarative UI UI = f(state); describe, don't mutate.
BuildContext Handle to a widget's place in the tree.
Ephemeral state UI-only state (toggles, controllers); StatefulWidget is fine.
Business/App state State others care about; managed by BLoC/Cubit.
Entity Pure business object (Domain), no JSON/framework.
Model Entity + JSON serialization (Data layer).
Use Case One business action; the only thing a BLoC calls.
Repository (contract) Abstract "what we can do" (Domain).
Repository (impl) Concrete "how" (Data); maps Model→Entity, Exception→Failure.
Data Source Lowest-level fetch/store; returns Models, throws Exceptions.
Exception Low-level error thrown in Data.
Failure Business-friendly error returned upward.
Result/fold Either-success-or-failure container forcing both branches.
BLoC / Cubit State managers: Events→States / methods→States.
Event / State Input to / output from a BLoC.
DI / Service Locator GetIt registry that supplies dependencies.
Singleton / LazySingleton / Factory One-now / one-on-first-use / new-each-time registrations.
GoRouter Declarative router with guards, params, nesting, shells.
Redirect / Guard Pre-navigation hook enforcing auth.
Constraint model "Constraints down, sizes up, parent positions."
Debounce Wait for a typing pause before acting.

Final Word

You now hold the full picture. Domain holds the rules; Data does the dirty work and fulfills the rules; Presentation shows the rules to the user; Core + DI + GoRouter glue it together. Every dependency points inward, every piece is testable in isolation, and every new showcase page is just another tidy feature folder plugged into the same backbone.

Build one feature end to end (start by re-creating Authentication from Chapter 11), run its tests, and the pattern becomes muscle memory. From there, every widget in the catalog is just another exhibit in the same well-built museum.

End of handbook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment