--- name: flutter description: "Use when building, structuring, testing or optimizing a Flutter app — feature-first layering, Riverpod 3 or Bloc, typed go_router, freezed models, a dio data layer, Material 3, jank hunting, widget/golden tests. Targets Flutter 3.44 / Dart 3.12. NOT React Native (that is `react-native`), NOT Compose Multiplatform (that is `compose-multiplatform`)." tags: [flutter, dart, mobile, app, ios, android] recommends: [design, deployment] origin: risco --- # Flutter & Dart app architecture The opinionated default stack for a production Flutter app: **feature-first + layered** folders, **Riverpod 3** with codegen for shared/async state, a **typed go_router**, **freezed** immutable models, a **dio** data layer, and explicit `Result` error modeling — all on **Material 3**. Escape hatches are first-class: **Bloc/Cubit** instead of Riverpod when the team already runs Bloc, and raw `http`/`get_it` are allowed — but **pick one of each per app, never mix two**. Pinned versions this skill targets: **Flutter 3.44 / Dart 3.12**, **Riverpod 3.0**, **go_router 17.2.x** (+ `go_router_builder 4.3.x`), **freezed 3.x** / `json_serializable`, **dio 5.x**, **mocktail 1.x**. ## Boundaries > **⚠️ SDD new-feature gate — read this first.** If this skill fired on a **new, non-trivial feature or behaviour change** and there is **no approved spec + plan** under `02-DOCS/wiki/sdd/`, STOP — do **not** write feature code yet. Hand off to `../specify/SKILL.md` first: it runs brainstorm → spec → plan → tasks before any code, then routes back here once the plan is approved. Build here directly only for a genuinely one-line / low-risk change. Method: `../sdd/SKILL.md`. This skill owns the `pubspec.yaml` subproject and nothing else in the repo. Hand off when the UI is Compose Multiplatform (`compose-multiplatform`), SwiftUI/native iOS (`swift-ios`) or React Native (`react-native`); when the work is on a FastAPI/Go/Next.js sibling in the same monorepo (use that skill). For a pure Dart **server/CLI** with no widget tree, general Dart applies but skip the UI/nav/perf references. For a single-file throwaway sample, say architecture is overkill and do not impose layering. Around the edges: `harness` owns the workspace `01-TOOLS`/`02-DOCS` layer and flavor secrets; `fastapi`, `go` and `nextjs` build the backends this app talks to; `secure-coding` reviews token handling and deep-link validation; `deployment` handles store/CI release; `design` owns the Material 3 token system. ## Decision rules | Situation | Do this | Not that | |---|---|---| | Ephemeral UI state (checkbox, slider, anim) | `setState` / `ValueNotifier` locally | a global provider | | Shared / async state | Riverpod `@riverpod` `Notifier`/`AsyncNotifier` | scattered `setState` across pages | | Team already on Bloc | Cubit (simple) / Bloc (event-sourced) | mixing Bloc + Riverpod in one app | | Multi-state async | `AsyncValue` / sealed state | `bool isLoading` + `bool isError` flags | | Navigation | one typed go_router | mixing `Navigator.push` with declarative routes | | Errors at domain boundary | `Result` / sealed | leaking `DioException` / raw `throw` to UI | | Models / DTOs | `@freezed abstract class … with _$Name` | hand-written mutable classes | | Cross-feature data | repository behind an interface | widgets calling `dio`/DB directly | ## Project layout ```text lib/ main.dart # bootstrap (shared) main_dev.dart # flavored entrypoint -> runApp(const App(flavor: Flavor.dev)) main_prod.dart app.dart # MaterialApp.router + ProviderScope wiring src/ features/ cart/ presentation/ # widgets, screens, Riverpod consumers domain/ # entities, repository interfaces, Result/Failure (zero Flutter imports) data/ # DTOs, dio data sources, repository impls common/ router/ # typed go_router + guards theme/ # ColorScheme.fromSeed, ThemeExtension tokens network/ # dio client + interceptors errors/ # Result, Failure sealed types widgets/ # shared reusable widgets ``` Dependencies point inward — `presentation → domain ← data`; `domain/` has **zero Flutter imports**. See `references/architecture-and-state.md` for the full layering contract and a worked cart feature. ## Dart 3.12 idioms **Null safety** — never reach for `!`: ```dart // BAD — bang crashes in prod when user is null final n = user!.name; // GOOD — null-aware + fallback final n = user?.name ?? 'Unknown'; // GOOD — if-case pattern promotes the binding if (user case User(:final name)?) { greet(name); } // GOOD — switch expression over a nullable is exhaustive final label = switch (user) { User(:final name) => name, null => 'Guest', }; ``` **`late`** — only for guaranteed-before-first-access, prefer `late final`: ```dart // BAD — defers a null error to runtime late String id; // OK — initialized in initState before any access late final AnimationController _c; ``` **Records + destructuring** for concurrent multi-return (parallel, not sequential): ```dart // Runs both requests at once; .wait is the Dart 3 record concurrency extension. final (user, count) = await (repo.user(), repo.count()).wait; ``` **Sealed + exhaustive switch** eliminates impossible states: ```dart sealed class JobState {} final class JobIdle extends JobState {} final class JobRunning extends JobState { const JobRunning(this.pct); final double pct; } final class JobDone extends JobState { const JobDone(this.url); final String url; } Widget build(JobState s) => switch (s) { JobIdle() => const Text('Idle'), JobRunning(:final pct) => LinearProgressIndicator(value: pct), JobDone(:final url) => Link(url), }; // compiler errors if a variant is unhandled ``` **async-gap guard** after every `await` that precedes a `context`/`ref` use: ```dart // In a State: await repo.save(); if (!context.mounted) return; context.go('/done'); // Inside a Notifier (Riverpod 3): await repo.save(); if (!ref.mounted) return; ref.invalidate(listProvider); // Fire-and-forget must be explicit, not a silently-dropped Future: unawaited(analytics.log('checkout')); ``` **Streams** belong in a `StreamBuilder`, never a manual `.listen()` in `build`: ```dart // BAD — leaks a subscription on every rebuild @override Widget build(BuildContext context) { stream.listen(_onData); return const SizedBox(); } ``` **Extension types** give zero-cost ID type-safety so the compiler rejects raw strings: ```dart extension type UserId(String value) {} extension type OrderId(String value) {} void loadUser(UserId id) { /* ... */ } // loadUser('o_42'); // BAD — compile error: String is not a UserId loadUser(const UserId('u_7')); // GOOD ``` **Isolates** push CPU-bound work off the UI thread: ```dart final parsed = await Isolate.run(() => heavyParse(jsonBig)); ``` Error modeling → `references/architecture-and-state.md`; isolates deep dive → `references/performance.md`. ## State management: Riverpod 3 (default) ```dart // Sync Notifier — list mutation. (Function providers for async reads and // AsyncNotifier guarded mutation -> references/architecture-and-state.md.) @riverpod class CartNotifier extends _$CartNotifier { @override List build() => const []; void add(CartItem item) => state = [...state, item]; void remove(String id) => state = state.where((i) => i.id != id).toList(); } ``` Render `AsyncValue` with an exhaustive switch; scope rebuilds with `.select()`: ```dart final view = switch (ref.watch(productsProvider)) { AsyncData(:final value) => ProductList(value), AsyncError(:final error) => ErrorView(error), _ => const CircularProgressIndicator(), }; final count = ref.watch(cartNotifierProvider.select((items) => items.length)); ``` `ref.watch` rebuilds on change; `ref.read` is for callbacks only; `ref.listen` is for side-effects. Riverpod 3 unifies Notifier/AsyncNotifier, merges `autoDispose`/`family` into the single `@riverpod` annotation, exposes one `Ref` type, and adds automatic retry, a `Mutation` API, and `@Riverpod(keepAlive: true)`. Legacy `StateProvider`/`ChangeNotifierProvider` live in `package:riverpod/legacy.dart` — **not for new code**. Wrap the app root in `ProviderScope`. Codegen, `Mutation`, family-as-arg, persistence and the DI graph → `references/architecture-and-state.md`. Testing → `references/testing.md`. ## State management: Bloc/Cubit (the alternative) Cubit for simple state, Bloc (event → state) for complex/event-sourced flows. ```dart sealed class AuthState {} final class AuthInitial extends AuthState {} final class AuthLoading extends AuthState {} final class AuthAuthed extends AuthState { const AuthAuthed(this.user); final User user; } final class AuthFailed extends AuthState { const AuthFailed(this.message); final String message; } class AuthCubit extends Cubit { AuthCubit(this._repo) : super(AuthInitial()); final AuthRepository _repo; Future login(String email, String password) async { emit(AuthLoading()); final res = await _repo.login(email, password); emit(res.fold((u) => AuthAuthed(u), (f) => AuthFailed(f.message))); } } // UI: BlocBuilder( builder: (context, state) => switch (state) { AuthInitial() || AuthLoading() => const CircularProgressIndicator(), AuthAuthed(:final user) => HomeView(user), AuthFailed(:final message) => ErrorView(message), }, ); ``` ```dart // BAD — a Bloc that depends on another Bloc CartBloc(this.authBloc); // GOOD — share the repository, not the Bloc CartBloc(this.cartRepo); ``` **Pick one per app, never both.** Full event-driven Bloc, `BlocObserver`, and `hydrated_bloc` → `references/architecture-and-state.md`. ## UI & navigation (essentials) - Extract widgets to **classes, not `_build*()` methods** — enables `const`, element reuse and `RepaintBoundary` granularity. Use `const` everywhere; `ValueKey` in lists, **never `UniqueKey` in `build`**. - Material 3 theming from a seed; read tokens via `Theme.of(context)`: ```dart final theme = ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), ); // BAD color: Colors.blue // GOOD color: Theme.of(context).colorScheme.primary ``` - Typed go_router skeleton: ```dart @TypedGoRoute(path: '/', routes: [TypedGoRoute(path: 'detail/:id')]) class HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute(); @override Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); } final router = GoRouter( routes: $appRoutes, refreshListenable: authListenable, redirect: (context, state) => authGuard(context, state), ); const DetailRoute(id: '7').go(context); // typed navigation, no magic strings ``` Slivers, adaptive/responsive, deep links, `StatefulShellRoute`, design tokens and a11y → `references/ui-and-navigation.md`. ## Data layer ```dart final dio = Dio(BaseOptions( baseUrl: const String.fromEnvironment('API_URL'), connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), )); dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await secureStorage.read(key: 'auth_token'); if (token != null) options.headers['Authorization'] = 'Bearer $token'; handler.next(options); }, onError: (error, handler) async { final isRetry = error.requestOptions.extra['_isRetry'] == true; // one-shot guard if (!isRetry && error.response?.statusCode == 401 && await refreshToken()) { error.requestOptions.extra['_isRetry'] = true; return handler.resolve(await dio.fetch(error.requestOptions)); } handler.next(error); }, )); ``` ```dart // GOOD — boundary returns a mapped Result; UI cannot crash on a wire error Future> getCart(); // BAD — leaks DioException into widgets Future getCart(); // throws DioException to the UI ``` DTOs are freezed/`json_serializable` and mapped via `CartDto.toDomain()`; **DTO ≠ entity**. Full repository + `Result`/`Failure` + caching → `references/architecture-and-state.md`. ## Testing (gate) ```dart // Unit — Riverpod 3 container helper. final container = ProviderContainer.test(); final cart = container.read(cartNotifierProvider); // Widget — override the controller with a fake. await tester.pumpWidget(ProviderScope( overrides: [cartControllerProvider.overrideWith(FakeCartController.new)], child: const MaterialApp(home: CartScreen()), )); // Golden — deterministic pixel comparison. await expectLater(find.byType(CartCard), matchesGoldenFile('goldens/cart_card.png')); ``` **Every async state transition has a test (loading → data, loading → error).** `pumpAndSettle` hangs on infinite animations (spinners) — use an explicit `pump(const Duration(milliseconds: 300))` there. Full pyramid, repository tests, `blocTest`, golden determinism and coverage → `references/testing.md`. ## Performance (essentials) - `const` + extract-to-class so only the changing subtree rebuilds. - `RepaintBoundary` around independently-animating subtrees; `ListView.builder` for long lists. - `cacheWidth`/`cacheHeight` to decode-at-size; cached network images with placeholder/error. - Scoped consumers via `.select()` / `BlocSelector` / `buildWhen`. - Profile in `flutter run --profile`; DevTools → "Track Widget Rebuilds", raster vs UI thread. Rebuild/paint/jank workflow, isolates and build flavors → `references/performance.md`. ## Localization & dependency hygiene (essentials) - l10n via first-party `flutter_localizations` + `gen_l10n` (set `generate: true`, add `l10n.yaml`); one **ARB** file per locale, strings read type-safely through `AppLocalizations.of(context)`. - Plurals/genders use **ICU** syntax inside the ARB (`{count, plural, =0{…} =1{…} other{…}}`), never an `if (count == 1)` ladder in Dart. - RTL: use `EdgeInsetsDirectional`/`AlignmentDirectional` (auto-mirrors); mirror directional icons, never logos or numbers. Format numbers/dates/currency with `intl` `NumberFormat`/`DateFormat` (locale-aware), never by hand. - Before adding a dependency, check its **pub points**/popularity/last-publish on pub.dev; audit with `flutter pub outdated`. In a multi-package repo, **melos** orchestrates bootstrap/scripts and `package:` encapsulation (public API via `lib/.dart`, internals under `lib/src/`, enforced by `implementation_imports`). ARB + ICU plurals, RTL geometry, locale-aware formatting, pub points/pana, `melos` and workspace encapsulation → `references/i18n-and-dependencies.md`. ## Production checklist - `FlutterError.onError` + `PlatformDispatcher.instance.onError` + `ErrorWidget.builder` wired to Crashlytics/Sentry. - Secrets via `--dart-define` / `--dart-define-from-file`; tokens in secure storage (Keychain / EncryptedSharedPreferences), **never plaintext**. - HTTPS only. - Strict `analysis_options.yaml`: `strict-casts` / `strict-inference` / `strict-raw-types` + `flutter_lints` or `very_good_analysis`. - l10n via `flutter_localizations` + ARB (ICU plurals, RTL-safe geometry, locale-aware `intl` formatting); a11y (48px targets, `Semantics`, contrast ≥ 4.5:1). - Dependency hygiene: `pubspec.lock` committed for apps, `flutter pub outdated` audited on a cadence, dependencies vetted by pub points before adding. - No `print()` → `dart:developer` `log()`. - Gate the branch with `scripts/verify.sh`, run inside the Flutter project (format / codegen / analyze / tests). ## Anti-patterns | Anti-pattern | Why it fails / do instead | |---|---| | `user!` to unwrap | bang crashes in prod; use `?.`/`??` or an if-case pattern. | | `_buildHeader()` helper methods | extract to a `const` widget class — enables element reuse + const propagation. | | `setState` at the top of the page | rebuilds the whole subtree; scope it or `.select()`. | | `Navigator.push` mixed into go_router for one screen | one router; mixing breaks deep links + back stack. | | `context` used after an `await` | guard `context.mounted` / `ref.mounted`; a stale context crashes. | | hardcoded `Colors.blue` | use `colorScheme`; hardcoding breaks dark mode + theming. | | `ListView(children: [...])` for a feed | use `.builder`; the concrete form builds all children eagerly. | | `catch (e)` on everything | use `on`-typed clauses; never catch `Error` (it is a bug). | | raw `DioException.toString()` shown to the user | map to a `Failure` with a localized message. | | `print()` for logging | use `dart:developer` `log()` — has levels and can be filtered. | ## Project grounding (02-DOCS) In a project with a `02-DOCS/` layer (the [`harness`](../harness/SKILL.md) wiki), this app's decisions live in `02-DOCS/wiki/stack/flutter.md`, indexed in `02-DOCS/wiki/index.md`. Read it first and stay consistent. Missing or stale? Write the real choices there — state management (Riverpod/Bloc), the architecture layers, routing, the Material 3 token system, codegen setup — index it, and bump its `Updated` date in the same change a convention changes, so the next agent inherits it instead of re-deriving it. No `02-DOCS/` layer? Skip silently: technical conventions are *recorded, not gated*, so never block the task on this.