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.
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.
Part I — Foundations
Part II — The Architectural Backbone
- Clean Architecture
- Serialization
- Dependency Injection
- Networking
- State Management with BLoC
- Navigation
- Error & Loading States
Part III — A Complete Feature, End to End
Part IV — The Component Showcase
- Forms
- Lists
- Pagination
- Search
- Dialogs
- Animations
- Layouts
- Media
- File Management
- Local Storage
- Charts
- Calendar
- Maps
- Shared Components
Part V — Quality & Mastery
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.
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.
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
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
buildmethod. - StatefulWidget — describes UI that can change over its lifetime internally (e.g., a checkbox toggling). It keeps a companion
Stateobject.
Crucial clarification for this handbook: A
StatefulWidgetis not forbidden. What we forbid (per the architecture rules) is usingsetStateandProviderto manage application/business state.StatefulWidgetis still the correct tool for ephemeral UI state that no one else cares about — aTextEditingController, 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']
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:
- Don't use a
contextafter the widget may be gone. After anawait, the widget might have been removed from the tree. Guard withif (!context.mounted) return;before using it. .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 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.
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.
- Thinking imperatively. Trying to "find a widget and change it." Instead: change state, let
buildre-run. - Using
setStatefor business logic. It feels easy, then becomes unmaintainable. - Using
contextafterawaitwithout checkingmounted. Causes crashes. - Putting everything in one file because "it works for now." It won't later.
- Keep widgets small and focused; compose rather than nest deeply in one file.
- Separate ephemeral from business state from day one.
- Prefer
constconstructors wherever possible — they let Flutter skip rebuilding unchanged subtrees (a real performance win). - Name things for what they are, not how they're built.
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).
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/.)
Always, for any app you expect to maintain, grow, or test. Even a "small" app that ships to users benefits.
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.)
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
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.
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.
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
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.
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 | 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 |
- 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.
- "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.
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.
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.
For anything reused across features, or anything with no natural feature owner (the HTTP client, the theme, the error vocabulary).
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.
| ✅ 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 | — |
dependency_injection/— the singleinit()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) andfailures.dart(returned to UI). (Chapter 4 & 10.)usecase/— theUseCasebase contract and theResulttype. (Chapter 4.)themes/—ThemeDataso 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.
// 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),
),
),
),
);
}// 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';
}// 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)));
}
}// 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);
}- Letting Core depend on a feature (it must depend on nothing feature-specific).
- A single giant
Utilsclass with 40 unrelated methods — split by purpose. - Hard-coding colors/strings in screens instead of theme/constants.
- 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.
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:
- Entity — a pure business object.
- Model — an Entity plus JSON serialization.
- Use Case — one business action.
- Repository Contract — an abstract "what we can do."
- Repository Implementation — the concrete "how we do it."
- Data Source — the lowest-level fetch/store.
- Exception — a thrown technical error (Data layer).
- Failure — a returned business-friendly error (Domain/Presentation).
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).
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.
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.
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
- 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.
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 => [];
}| 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
FailureextendsEquatable: 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 assertexpect(state, AuthError(AuthFailure('x'))).
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();
}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.
- 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.
- Returning a
Modelfrom a Use Case (leak JSON into Domain). Return the Entity. try/catchin the BLoC for network errors (that's the repository's job).- Domain importing
http/json_annotation.
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
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).
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.
Any time data crosses the boundary between your app and the outside world (API, disk). Models live in the Data layer only.
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.
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.
// 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-outputsBecause 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);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'));
});explicitToJson: truewhen you have nested models (otherwise nestedtoJsonisn't called).- Provide
defaultValue/ nullable types for fields the API may omit. - Keep generated
*.g.dartout of manual edits; regenerate instead. - Commit generated files or generate in CI — be consistent.
- Forgetting
part 'x.g.dart';→ generator errors. - Editing the
.g.dartby hand (it'll be overwritten). - Putting
@JsonSerializableon the Entity (pollutes Domain). - Forgetting to re-run build_runner after changing a model.
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
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.
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.
For any object with dependencies that you want to reuse, swap, or test — repositories, data sources, use cases, BLoCs, the http client, storage.
Trivial value objects and pure functions don't need DI. Don't over-engineer a constant into a registered dependency.
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.
| 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.
registerFactorygives each screen a fresh BLoC. (Use Cases/repositories are stateless, so singletons are fine and cheaper.)
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.
In main.dart: await init(); before runApp. In a page: BlocProvider(create: (_) => sl<AuthBloc>()).
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.
- Register against abstractions (
AuthRepository), not concretions. - BLoC/Cubit = factory; everything stateless = lazy singleton.
- One
_initX()per feature; call them all frominit(). - Call
init()once, beforerunApp.
- 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 touchsl.
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
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.
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.
For all remote data access, from the Data layer's data sources only. Presentation and Domain never make HTTP calls.
Don't call HTTP from widgets or BLoCs. Don't scatter raw http.get around — always go through the wrapper.
| 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 |
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 '';
}
}// 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 not5xxautomatically unless idempotent — be careful retrying non-idempotentPOSTs. This is a deliberate, safe default.
HttpClientsends the request, applies timeout/retry.HttpStatusHandlerreads the status code → returns body (2xx) or throws a typed Exception (4xx/5xx).- The data source lets the Exception propagate (or parses the body).
- The repository
catches the Exception and returns aFailure. - The BLoC receives
Result, emits an error State. - 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
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.)
- Wrap
http; never import it outsidecore/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.
- Parsing error bodies inconsistently across data sources.
- Retrying
POSTs blindly (duplicate side effects). - Letting raw
httpexceptions reach the UI.
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.
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
| 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.
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.
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.
| 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 aBlocListener/BlocConsumer.listener.
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))],
),
},
);Three approaches, in order of preference:
- Shared Use Case / Repository — the cleanest: both BLoCs depend on the same repository; one writes, the other reads. No direct coupling.
BlocListenerbridging — a widget listens to BLoC A and dispatches an event to BLoC B. Keeps BLoCs unaware of each other.- 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')),
);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])],
);- States immutable +
Equatable. Events immutable +Equatable. - BLoC calls Use Cases only (never repositories/data sources directly).
- No
BuildContextin a BLoC — keep it testable. - One BLoC per feature concern; no god-BLoC.
- Always handle every branch (loading → success | empty | error).
- Dispose:
BlocProviderauto-closes BLoCs it creates.
- Navigating in
BlocBuilder(useBlocListener). - Mutating a list in place and re-emitting it — Equatable sees "no change," UI won't rebuild. Emit a new list.
- Forgetting
Equatableprops → missed or duplicate rebuilds. - Sharing one BLoC instance across screens (register as factory).
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
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).
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.
For all app navigation. Once you adopt GoRouter, never use raw Navigator for top-level navigation.
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).
- Named routes, route parameters, nested routes, ShellRoute (persistent shell like a bottom nav), deep links, redirects, authentication guards.
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';
}// 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}'))),
);
}context.goNamed(AppRoutes.homeName); // replace stack
context.pushNamed(AppRoutes.productDetailName, // push (keeps back)
pathParameters: {'id': '42'}, queryParameters: {'ref': 'list'});
context.pop(); // go backBecause 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.
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.
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.
- 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).
- Mixing raw
Navigatorwith GoRouter (inconsistent stack). - Putting business logic in
redirectbeyond auth checks. - Forgetting
context.mountedchecks when navigating afterawait.
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]
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.
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.
- Data source calls
HttpClient; on a bad status theHttpStatusHandlerthrows a typedException. - The repository
catches it and returns aFailure(technical → business). - The BLoC receives
Result.fold(...)and emits a state:Loading→Error/Empty/Loaded. - 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)
// 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),
]),
);
}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: the error state carries enough context for the UI to re-dispatch the original event (
onRetry: () => bloc.add(Requested())). - Offline: a
connectivityservice incore/services/exposes a stream; a top-levelBlocListener/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;
}- 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.
- 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.
- 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.
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.
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).
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.
Whenever the app has protected resources or per-user data.
A fully public, anonymous app needs none of it — don't add auth complexity you don't use.
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();
}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/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/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 attemptrefreshSession()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 theHttpClientas an interceptor. Either way, the policy (when to refresh, when to give up) is a single, testable place.
// 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/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
BlocListenernavigates andBlocBuilderdraws the button: Builder can run many times — unsafe for navigation. Listener runs once per state change — perfect for navigation/snackbars. Builder draws, Listener acts.
Registered in _initAuthentication() (see Chapter 6): BLoC as factory, use cases/repository/data source as lazy singletons, repository bound to the abstract AuthRepository.
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
- Use case: mock repo; verify forwarding and result.
- Repository: mock data source + storage; assert tokens saved on success and
AuthException → AuthFailure. - BLoC:
blocTestfor[Loading, Authenticated]and[Loading, Error]. - Widget: mock
AuthBloc; assert spinner onAuthLoading, navigation onAuthAuthenticated.
(All shown in Chapter 26.)
- 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.
- Storing tokens in SharedPreferences.
- Navigating from
BlocBuilder. - Forgetting to persist the refresh token.
- Infinite refresh loops (retry refresh on a failed refresh).
Focused variant. Forms are mostly ephemeral UI state (the current text, whether a checkbox is ticked). The right tool here is a
StatefulWidgetwith 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.
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.
| 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 |
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).
// 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;
}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'),
),
]),
),
);
}
}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],
);- ✅ Always
dispose()controllers (memory leaks otherwise). - ✅ Keep validators pure & in Core (reusable + testable).
- ✅ Use
TextFormFieldinsideFormfor grouped validation. - ❌ Don't build a BLoC for each field; ❌ don't validate by hand when
Formdoes it; ❌ don't forget to gate submit on bothvalidate()and required toggles.
Focused variant. Lists display data that usually comes from a BLoC's
Loadedstate. The list widgets themselves are pure UI.
Lists render collections. Flutter's builders create children lazily (only what's visible), which is essential for long lists' performance.
| 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 |
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.
// 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'))],
);- ✅ Use
.builderfor 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).
Full-ish variant — pagination genuinely needs BLoC state design.
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.
Use for any list that can grow large (feeds, search results, catalogs). Avoid for small, fixed lists — added complexity for no gain.
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,
)),
);
}
}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));
},
),
);
},
);
}- Test:
FeedLoadMorewhileisLoadingMoreemits nothing (guard works); appends items; stops athasMore: 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."
Full-ish variant — search needs debounce, a perfect job for a Bloc with an event transformer.
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.
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.
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
Timerthat you cancel/restart in the widget before adding the event — but the transformer approach is cleaner and cancels in-flight requests too.
- ✅ 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).
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.
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.
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).
// 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')),
]),
),
);- ✅ Return values via
Navigator.pop(context, value); ✅context.mountedcheck after awaiting a dialog. - ❌ Don't bury business logic inside the dialog; ❌ don't use dialogs for long forms.
Focused variant. Animations are pure UI polish; controllers are ephemeral state →
StatefulWidgetwithTickerProvideris correct, not BLoC.
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.
Use to clarify state changes and spatial relationships. Avoid gratuitous animation that delays the user or animates on every rebuild unintentionally.
// 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),
),
);- ✅ Always
dispose()controllers; ✅ keep durations short (200–400 ms); ✅ uniqueHerotags. - ❌ Don't manage animation state in BLoC; ❌ don't animate large subtrees needlessly (jank).
Focused variant + a deep dive into Flutter's constraint model, the single most important layout concept.
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
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).
| 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 |
// 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'))]),
),
);- ✅ Think in constraints; ✅ use
Expanded/Flexibleinside 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 (useLayoutBuilder/MediaQuery).
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).
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).
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;
}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),
]),
);- ✅ 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.
Full-ish variant — uploads/downloads are real Data-layer operations with use cases.
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.
// 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);
}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);
}- ✅ 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.
Full-ish variant — choosing the right storage is an architectural decision.
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.
- 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.
// 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()); }
}- ✅ 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.
Focused variant. Charts visualize data from a
Loadedstate. Use a package likefl_chart.
Charts turn numbers into insight: line (trends over time), bar (comparisons), pie (parts of a whole).
The BLoC fetches/derives the data (Domain entities → simple chart points in the presentation layer). The chart widget is pure UI.
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'),
]));- ✅ 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.
Focused variant. Use
table_calendar; events come from a BLoC.
Calendar view (month/week), events (markers on days), and an agenda (list of a day's events). Useful for bookings, schedules, planners.
A CalendarBloc loads events (use case → repository → API). The widget renders the month and, on day selection, shows that day's agenda from state.
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.- ✅ 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.
Focused variant. Maps + location are platform services behind interfaces.
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.
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)},
);- ✅ 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.
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.
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.
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).
// 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.)
- ✅ 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.
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.
To ship with confidence, refactor safely, and document behavior. A good suite catches regressions before users do.
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
Lots of fast unit tests at the base; fewer, slower widget/integration tests on top. Test behavior, not implementation details.
flutter_test— assertions,testWidgets.mocktail— mocks without code-gen.bloc_test—blocTestfor asserting emitted states.
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);
});
}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')));
});
}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');
});
}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')]);
}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);
});
}- ✅ 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.
- S — Single Responsibility: one reason to change per class.
HttpStatusHandleronly maps codes;SecureStorageServiceonly stores; a BLoC manages one feature's state. - O — Open/Closed: extend without modifying. Add a new
Failuresubtype orUseCasewithout editing existing ones. - L — Liskov Substitution:
UserModelsubstitutes forUseranywhere. - I — Interface Segregation: small focused contracts (
AuthRepository) not one giant interface. - D — Dependency Inversion: depend on
AuthRepository(abstraction), DI injectsAuthRepositoryImpl.
- Files
snake_case.dart; classesPascalCase; 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: new feature = one folder (3 layers) + one
_initX()in DI. Nothing else changes. - Maintainability: isolated layers contain change. Swap
http→dio: onlyHttpClient. Redesign login: onlypresentation/. - Reusability: shared UI/utils in Core; never copy-paste across features.
"If deleting a feature folder leaves the app compiling (minus that feature), the boundaries are correct."
- 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
Navigatorwhen using GoRouter. setState/Providerfor business state (ephemeral UI state withStatefulWidgetis fine).
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)
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.
// 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.4Startup order matters: bindings → DI init → runApp → router, because the router's guard resolves SecureStorageService from DI.
| 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. |
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.