building-flutter-apps
>-
What it does
Gate
On skill activation, emit verbatim once:
building-flutter-apps active. Pre-flight required.
Before writing any .dart code, emit verbatim:
Reading building-flutter-apps gate.
After every code change to a .dart file (or to pubspec.yaml / build.yaml / analysis_options.yaml):
- Run
dart analyzefrom the package root. Block on any ERROR or WARNING. - Emit the filled-in Pre-Flight checklist. T0 always. T1 / T2 only if their domain was touched.
- If
dart analyzeis not wired withflutter_skill_lints, run Setup before continuing.
Critical Rules
-
Use
dart analyzefrom package root, neverflutter analyzeand never path-scoped. Copy references/analysis_options.yaml to project root and wireflutter_skill_lints+riverpod_lintunderplugins:.flutter analyze libsilently drops plugin diagnostics (flutter#184190). -
Use
@riverpod/@Riverpodcodegen for every provider — state, computed, repository, datasource, service, family, stream. Never manualProvider,FutureProvider,StreamProvider,StateProvider,StateNotifierProvider,NotifierProvider,AsyncNotifierProvider,ChangeNotifierProvider. Rundart run build_runner watch --delete-conflicting-outputs. -
Guard every
awaitin notifiers and repositories withif (!ref.mounted) return;. Guard everyawaitin widgets andStatewithif (!context.mounted) return;. InsideState, neverif (mounted)— alwaysif (!context.mounted) return;. Insidefinally, use the guard formif (ref.mounted) { ... }— neverif (!ref.mounted) return;. -
Extract widgets to public classes. No
_buildXxx()helpers. Noclass _Foo extends StatelessWidget | StatefulWidget | ConsumerWidget | ConsumerStatefulWidget | HookWidget | HookConsumerWidget. Mark file-internal widgets@visibleForTesting._FooState extends State<Foo>stays private (Flutter convention — exempt). -
Use
Object?or a specific type for unknown values.dynamiconly forMap<String, dynamic>JSON. Nevervalue!— useif (value case final v?). -
Use
AppLocalizations(gen-l10n) for every user-facing string. Never hardcode UI copy in widgets, notifiers, repositories, or datasources. In widgets, bindfinal l10n = context.l10n;at the top ofbuildand usel10n.someKey; never chaincontext.l10n.someKey.*Stringsconstants only for non-user-facing IDs. For l10n config, put ARB files inarb-dir(lib/l10nby default). Generated Dart is written to${arb-dir}/${output-localization-file}unlessoutput-diris set; importapp_localizations.dartfrom that directory. -
Use
sealed classfor Freezed unions and states. Neverabstract classwith@freezed. Match with Dart nativeswitch— never Freezed.when()/.map(). For VOs in/domain/values/, annotate@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)to disable codegen of those methods entirely. Lint:freezed_disable_map_when_required. -
Never prop-drill state. Child widgets read providers directly with
ref.watch/ref.read/ref.listen. Do not pass entity / state / notifier instances through constructors. Constructor params allowed: immutable IDs (for routing/lookup), callbacks,Key, and primitive props on leaf atoms.ConsumerStatemay own lifecycle handles, not provider-derived*Cache/*Source/*DayStartfields; use computed providers or localbuildvalues. Lint:riverpod_consumer_state_derived_cache. -
Use a mixin when the same behavior appears in 2+ classes. Extract to a
mixinwith anonclause (e.g.mixin RetryMixin on AsyncNotifier<X>). Suffix the name withMixin. Copy-paste sharing across notifiers, widgets, or services is forbidden — replace with a mixin. -
Storage SDK calls live in Local Datasource, never in Notifier. Hive (
Hive.openBox,box.get/put/delete,Hive.box),SharedPreferences,flutter_secure_storage,dart:iofile ops,path_providerdirectory access — all live behind aLocal<X>Datasourceinterface, called by<X>Repository. Notifiers and widgets never importhive_ce/shared_preferences/flutter_secure_storage/dart:io/path_provider. -
Primitives →
core/extensions/. Never inline.DateTime/String/int/double/num/Duration/Iterable/BuildContextops (capitalize, timeAgo, currency, percent, clamp, format) incore/extensions/{type}_extensions.dart, barrelextensions.dart. Call.timeAgo/.capitalized/.asCurrency/.clamped(...)/.pluralized(...). Forbidden:'${s[0].toUpperCase()}${s.substring(1)}',DateTime.now().difference(...),DateTime.now().toUtc(),DateTime.timestamp(),DateTimeX.nowLocal().startOfDay,DateTimeX.nowLocal().calendarDaysBefore(...),NumberFormat.currency(...).format(...), inline.clamp(...). Raw executable strings and numbers are magic literals; move them to named constants, Value Objects, or semantic helpers. Current time helpers live inDateTimeX(nowUtc,nowLocal); current-day boundaries such asnowLocalStartOfDayand repeated local calendar windows such as "60 days ago" get semanticDateTimeXhelpers. Calendar-day helpers reconstruct date components; they do not subtractDuration(days: ...)across DST-sensitive local dates. Persisted/server timestamps MUST use UTC helpers, and local calendar bucketing of stored timestamps MUST convert to local first. Lints:datetime_now_requires_timezone_intent,avoid_magic_literals(suppress with a comment only when local raw time or literal ownership is intentional and app-specific). Why: SSOT — one fix updates every call site. Apply: 2+ uses → extension. Domain entities NEVER importcore/extensions/(outer dep,arch_domain_importERROR). Domain derive via entity getter (one-off) OR Value Object (cross-entity — Rule 12). See extensions-utilities.md. -
Wrap domain primitives in Value Objects. Domain-meaning
double/int/String(unit, currency, measure, identity, format) → sealed Freezed VO in/domain/values/. Raw redirects PRIVATE (._meters/._raw); public factories MUST contain explicit guards in body (if (v.isNaN) throw ArgumentError.value(...),if (!v.isFinite) throw ..., domain assertions), NEVER passthrough (factory X.unit(T v) => X._unit(v);is rejected — same risk as a public raw redirect). No named primitive factories on domain entities — convert at data/notifier/import boundaries. No hand-writtencopyWithin/domain/. Hive collision: Hive Models in/data/models/hold primitives only; VOs live on domain Entity, mapper bridges. Never change ctor param types or order on a@GenerateAdapters-registered class with shipped user data — silent box corruption,dart analyzeblind to disk. Apply: primitive in 2+ entities → VO. Baredouble distanceMetersat entity boundary = smell. See value-objects.md, hive-persistence.md. Lints:vo_public_raw_constructor(catches public redirect AND passthrough),domain_entity_primitive_factory,domain_custom_copy_with,hive_field_no_vo_type. -
Keep typed GoRouter routes as the navigation SSOT. Define routes once with
go_router_builder, then navigate with generated route helpers such asSomeRoute(...).go(context)andSomeRoute(...).push<T>(context). Keep route paths inside route definitions and generated helpers. Local sheets/dialogs use semantic helpers andNavigator.popfor dismissal. Navigation lints enforce typed routes, local modal helpers, and typed fallback behavior.
Trigger Map
Before writing code in any row below, output Reading: <ref-name> and read the listed reference(s).
| Touching | Read |
|---|---|
Notifier, AsyncNotifier, mutation method, ref.read / ref.watch / ref.listen, _ensureRepository, async cancellation, sync Notifier init | state-management.md |
Freezed entity, sealed union, fromJson / toJson, copyWith, model vs entity, build.yaml for explicit_to_json | freezed-sealed.md |
Provider declaration, @riverpod, family, keepAlive, codegen, Mutation<T> (experimental) | riverpod-codegen.md |
Repository, datasource, domain entity, layered architecture, IHttpService, mapping models to entities | architecture.md |
Value Object, primitive obsession, Distance/Money/Email/Slug, unit conversion in domain, cross-entity primitive, double distanceMeters/int amountCents/String email smell, arch_domain_import error | value-objects.md |
GoRouter, typed route, redirect, context.go, deep link, cold-start, navigation gate | architecture.md + deep-linking.md |
| HTTP, network, REST, source-of-truth fetch after mutation, transport id vs domain id | networking.md |
Atom, molecule, organism, design tokens, atomic widgets, core/widgets/ promotion | atomic-design.md |
Showcase, AppShowcaseTarget, startShowCase, replay, ShowcaseKeys, ProviderSubscription lifecycle | showcase-tours.md |
Widget test, ProviderContainer.test(), UncontrolledProviderScope, fakes, mocks, AppWidgetKeys, event-contract tests | testing.md |
flutter_driver, Dart MCP, E2E, integration_test, semantic selectors, log capture | dart-mcp-e2e-testing.md |
Hive, TypeAdapter, TypeId, box, persistence migration, retired field accounting | hive-persistence.md |
Crashlytics, error reporting, Crash facade, recoverable error classifier, symbol upload, three-hook wiring (FlutterError.onError + PlatformDispatcher.instance.onError + Isolate.current.addErrorListener), runZonedGuarded legacy (Flutter 3.3+) | crashlytics.md |
| Mixin, capability vs interface, retry helper, RNG, bulk operation | mixins.md |
Service, singleton, fire-and-forget, abstract final class, unawaited(), Future<void> signature | services-and-singletons.md |
@Preview, widget_previews.dart, preview fakes, deterministic preview data | widget-previews.md |
AppLocalizations, ARB file, gen-l10n, locale fallback, placeholders, plural / select | localization.md |
Performance, build cost, .select(), const constructors, ListView.builder, large list compute | performance.md + flutter-optimizations.md |
LayoutBuilder, RenderFlex overflow, Expanded / Flexible outside Row / Column, Positioned outside Stack, text-scale clamp | layout-diagnostics.md |
Extension, SnackBarUtils, snackbar dispatch from notifier, @visibleForTesting helpers, DateTime format/diff/timeAgo/startOfDay, String capitalize/truncate/titleCase/initials/format, int / double / num clamp/pluralized/asCurrency/percent/toFixed, Duration format, parse/format, NumberFormat, DateFormat, intl, core/extensions/ | extensions-utilities.md |
Records (x, y), extension type IDs, pattern matching, guard clause case _ when ... | dart-patterns-records.md |
analysis_options.yaml, dart analyze, plugin wiring, riverpod_lint pre-release pin, analyzer crash | analysis-options.md + analysis_options.yaml |
| Common navigation / form / list / debounce / route-param-fallback patterns | common-patterns.md |
Core Stack
Version SSOT: README.md → Core Stack.
| Package | Version | Purpose |
|---|---|---|
| flutter_riverpod + riverpod_annotation + riverpod_generator | ^3.3.1 / ^4.0.2 / ^4.0.3 | State management (codegen) |
| freezed + freezed_annotation | ^3.2.5 / ^3.1.0 | Immutable data classes, unions |
| go_router + go_router_builder | ^17.2.3 / ^4.3.0 | Declarative, type-safe routing |
| json_serializable + build_runner | 6.13.0 / ^2.15.0 | JSON serialization + code generation |
| showcaseview | ^5.0.2 | First-run guided tours |
| hive_ce + hive_ce_flutter + hive_ce_generator | ^2.19.3 / ^2.3.4 / 1.11.0 | Local persistence |
Architecture
graph LR
P[Presentation] --> R[Repository]
R --> Do[Domain]
R --> Da[Data]
Da -.-> Do
lib/
├── core/
├── features/
│ └── feature_x/
│ ├── data/ # Models, datasources (API / local)
│ ├── domain/ # Entities (pure Dart, no Flutter imports)
│ ├── repositories/ # Map models → entities
│ └── presentation/ # Notifiers, screens, widgets
└── main.dart
Repository returns Domain entities (never Models). Domain has no Flutter import. Datasource throws typed exceptions, never returns null on failure. try/catch lives in the Notifier — never in Domain or Datasource.
Class Modifiers
| Modifier | Extend outside lib | Implement outside lib | Instantiate | Mixin |
|---|---|---|---|---|
abstract class | ✓ | ✓ | ✗ | ✗ |
abstract interface class | ✗ | ✓ | ✗ | ✗ |
abstract final class | ✗ | ✗ | ✗ | ✗ |
sealed class | ✗ | ✗ | ✗ | ✗ |
base class | ✓ | ✗ | ✓ | ✗ |
interface class | ✗ | ✓ | ✓ | ✗ |
final class | ✗ | ✗ | ✓ | ✗ |
mixin class | ✓ | ✓ | ✓ | ✓ |
abstract interface class for repository / datasource / service contracts. sealed class for Freezed unions. abstract final class for pure stateless helper namespaces (Crash, Storage).
Code Generation
dart run build_runner watch --delete-conflicting-outputs
dart run build_runner build --delete-conflicting-outputs
dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs
Setup
- Copy references/analysis_options.yaml to project root. It already wires
flutter_skill_lints+riverpod_lintunderplugins:. flutter_skill_lintsis an analyzer plugin — it lives only inanalysis_options.yaml plugins:. Never add it topubspec.yaml.- Run
dart pub get. Confirmdart analyzeexits 0. - Sanity check: write
Widget _buildHeader() => const SizedBox();—dart analyzemust flag it.
Per-Tool Hooks
| Tool | Auto-install command | Hook source |
|---|---|---|
| Claude Code | /plugin marketplace add sgaabdu4/building-flutter-apps then /plugin install building-flutter-apps@building-flutter-apps; run /reload-plugins in the active session | hooks/hooks.json |
| Codex CLI | codex features enable hooks, codex features enable plugin_hooks, codex plugin marketplace add sgaabdu4/building-flutter-apps, then codex → /plugins → install | hooks/hooks.json |
| Copilot CLI | copilot plugin marketplace add sgaabdu4/building-flutter-apps then copilot plugin install building-flutter-apps@building-flutter-apps | hooks/hooks.copilot.json |
Raw skill installs are guidance-only. They load this file but cannot register runtime hooks or run scanners. Use plugin installs when enforcement matters.
Pre-Flight
Fill T0 always after any .dart write. Fill T1 if state / notifier / mutation touched. Fill T2 if network / E2E / stream / showcase / route touched. Emit before yielding the turn.
T0
-
dart analyzeexits 0 withflutter_skill_lints+riverpod_lintwired -
if (!ref.mounted) return;after everyawaitin notifiers and repositories -
if (!context.mounted) return;after everyawaitin widgets andState(no bareif (mounted)) - Inside
finally, use guard formif (ref.mounted) { ... }— never early-return - No
_buildXxx()and no private widget classes extending Stateless / Stateful / Consumer / Hook widgets (Statesubclasses exempt) - No
dynamicexceptMap<String, dynamic>for JSON; novalue! - Widgets bind
final l10n = context.l10n;before localized key reads; no chainedcontext.l10n.someKey - All providers
@riverpodcodegen; no manualProvider(...)family - No prop-drilling: children watch providers directly. No entity / state / notifier in constructors
- Shared behavior across 2+ classes lives in a
mixin(suffixedMixin), not copy-pasted - No
hive_ce/shared_preferences/flutter_secure_storage/dart:io/path_providerimports in notifier or widget files — storage goes throughLocal<X>Datasource→<X>Repository - No inline primitive ops outside
core/extensions/— use.capitalized/.timeAgo/.asCurrency/.clamped(...)/.pluralized(...)/DateTimeX.nowUtc(). Forbidden:'${s[0].toUpperCase()}...', date now/diff/UTC/timestamp chains, local calendar windows, ad-hocNumberFormat, inline.clamp(...), raw key/id/limit/threshold literals - Domain entity imports =
freezed_annotation+/domain/paths only. Zerocore/,data/,presentation/,package:flutter,dart:ui - Domain primitives with unit / currency / measure / identity wrapped in VO (
Distance/Money/Email) — no baredouble distanceMeters/int amountCents/String emailat entity boundary - VO raw redirects PRIVATE (
._meters/._raw); only validated factories public (vo_public_raw_constructor) - No named primitive factories on
@freezeddomain entities (factory User.fromPrimitives(...)forbidden — convert at data/notifier/import boundary) (domain_entity_primitive_factory) - No hand-written
copyWithin/domain/— let Freezed generate from the redirect (domain_custom_copy_with)
T1 — State / Notifier / Mutation
- Mutation methods (
create*,update*,delete*,set*,reorder*) init deps via_ensureRepository()/_ensureDependencies()lazily - Sync
Notifier.build()does not readstatebefore first return; seed via constructor; defer async withFuture.microtask -
ref.onDispose()cancels every subscription / controller / timer - No provider-derived
*Cache/*Source/*DayStart/*TodayStartfields inConsumerState; use computed providers or localbuildvalues - Notifier owns snackbar dispatch — widgets do not call
SnackBarUtils.show*orScaffoldMessenger.of(context) - Long-running sync / auth / import flows guard stale async writes
- No
ref.watchinside notifier method body —ref.watchinbuild()only;ref.readin callbacks
T2 — Network / E2E / Stream / Showcase / Route
- Source-of-truth fetch after mutation when backend generates / normalizes / reorders / derives values
- Observer + writer E2E proof present for shared / realtime / collaborative state
- All
ValueKeyfrom centralAppWidgetKeysregistry — no inline stringValueKey('...') - E2E entrypoint deterministic (
lib/main_dev.dartor equivalent); test overrides isolated frommain.dart - GoRouter redirect logic in pure
resolveAppRedirect(...), matrix-tested; nullable by-id provider for route params with fallback UI; call sites use generated typed route helpers -
startShowCase()receives full orderedShowcaseKeys.*Tourlist;ProviderSubscriptionstored and closed indisposeShowcase() - Cross-runtime constants / schema / function contracts have drift tests
- No
MediaQuery.withClampedTextScalinginMaterialAppbuilder
Recap
dart analyzefrom package root, neverflutter analyze. Plugin wired inanalysis_options.yaml plugins:, neverpubspec.yaml.- No
_buildXxx(). No private widget classes extending Stateless / Stateful / Consumer / Hook. Public +@visibleForTestingif file-internal._FooState extends State<Foo>stays private. if (!ref.mounted) return;after EVERYawaitin notifiers and repositories.if (!context.mounted) return;after EVERYawaitin widgets andState. Never bareif (mounted). Insidefinallyblocks, useif (ref.mounted) { ... }guard form, neverif (!ref.mounted) return;.- Every mutation method inits deps via
_ensureRepository()/_ensureDependencies(). Never rely onbuild()/_init()timing. sealed classwith Freezed, neverabstract class. Dart nativeswitch, never.when()/.map().- No prop-drilling. Child widgets watch providers directly. No entity / state / notifier as constructor params.
- Shared behavior across 2+ classes →
mixin XxxMixin on Y. No copy-paste sharing. - Storage SDK (Hive, SharedPreferences, secure_storage,
dart:io,path_provider) lives inLocal<X>Datasource. Notifiers and widgets never import storage SDKs directly. - Primitives →
core/extensions/. SSOT. Inline forbidden. Missing? Add to barrel, then call. Domain never importscore/extensions/— entity getter (one-off) or VO (cross-entity). - Domain primitives with unit / currency / measure / identity → sealed Freezed VO in
/domain/. See value-objects.md. - Typed GoRouter route classes are the navigation SSOT. Call generated route helpers directly; local modal helpers own sheet/dialog presentation and dismissal.
Capabilities
Install
Quality
deterministic score 0.46 from registry signals: · indexed on github topic:agent-skills · 10 github stars · SKILL.md body (21,625 chars)