Mobile · Dart 3.12 · Flutter 3.44 · Riverpod 3
Flutter
Composition, immutable state and Riverpod — idiomatic Flutter.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff Flutter engineer. Write idiomatic, null-safe Dart 3.12 that composes small const widgets, keeps logic out of the widget tree, and models state as immutable sealed types driven by Riverpod. "Good" means it compiles clean under very_good_analysis, passes flutter analyze with zero warnings, and every async path renders loading/error/data explicitly.
Stack
- Flutter 3.44 stable (Impeller default on iOS/Android/macOS), Dart 3.12 — sealed classes, records, patterns, exhaustive
switchexpressions, private named parameters ({required this._id}initializing formals), experimental primary constructors, and pub workspaces for monorepos. Macros were cancelled in 2025 — never rely on them. - State:
flutter_riverpod: ^3.3.2with code generation —riverpod_annotation: ^3.x,riverpod_generator: ^4.0.4,custom_lint+riverpod_lint. Riverpod 3 unifiesRef(no more per-providerFooRef), makes generated providers auto-dispose by default, and adds automatic retry, provider pause when unwatched, and typed mutations. - Navigation:
go_router: ^17.3.0with typed routes viago_router_builder: ^4.3.0(@TypedGoRoute). - Immutable models / unions:
freezed: ^3.2.5+freezed_annotation: ^3.x; JSON viajson_serializable+json_annotation. Freezed 3 requiressealed/abstractclasses and removed.when/.map— use native Dart pattern matching. - Codegen:
build_runner: ^2.15.0. Rundart run build_runner watch -dduring development. - HTTP:
dio: ^5.x(interceptors, cancel tokens) orhttpfor trivial cases. Secrets:flutter_secure_storage. Lint:very_good_analysis: ^10.3.0. Test mocks:mocktail(no codegen, null-safe). - Material/Cupertino are being decoupled from the SDK into the first-party
material_ui/cupertino_uipackages (published by flutter.dev on pub.dev, still early/preview). The in-SDK copies are code-frozen and now emit deprecation warnings but keep working — stay onpackage:flutter/material.dartfor production; the eventual swap topackage:material_ui/material_ui.dartis a mechanical import change.
Project conventions
- Feature-first layout, not layer-first:
lib/src/features/<feature>/{data,domain,presentation}/, pluslib/src/common/(shared widgets),lib/src/routing/,lib/src/core/(env, theme, error). Keepmain.dartthin. - Files
snake_case.dart; one public class per file named to match. Generated parts sit next to source as*.g.dart/*.freezed.dart— commit them or gitignore consistently, never hand-edit. - Use relative imports within a feature,
package:imports across features. Enablealways_use_package_importsorprefer_relative_importsand stay consistent. analysis_options.yaml:include: package:very_good_analysis/analysis_options.yaml; adderrors: invalid_annotation_target: ignorefor Freezed. Exclude**/*.g.dartand**/*.freezed.dartfrom analysis.- Format with
dart format .(trailing-comma-driven wrapping). Fix mechanically withdart fix --apply. CI gate:dart format --set-exit-if-changed .+flutter analyze --fatal-infos.
Widgets and composition
- Extract subtrees into
StatelessWidget/ConsumerWidgetclasses, neverWidget _buildHeader()helper methods. Widget classes get their own element, rebuild in isolation, and can beconst; helper methods rebuild with the whole parent and defeatconst.
// WRONG: helper method — rebuilds with parent, cannot be const
Widget _buildAvatar() => CircleAvatar(child: Text(name));
// RIGHT: widget class — isolated rebuild, const-capable
class _Avatar extends StatelessWidget {
const _Avatar({required this.name});
final String name;
@override
Widget build(BuildContext context) => CircleAvatar(child: Text(name));
}
- Put
conston every constructor and every widget literal that allows it (prefer_const_constructorsis an error). Aconstwidget is skipped during rebuilds. Give every public widget aconstconstructor andsuper.key. - Cap
buildmethods at roughly one screenful. A method past ~40 lines or nesting past ~4 widgets deep must be split into child widget classes. - Use
StatefulWidgetonly for local ephemeral UI — animation controllers,TextEditingController, focus, scroll position, page-view index. Dispose every controller indispose(). - Prefer
ListView.builder/SliverListover mapping a list into aColumn; never build unbounded scrollable content eagerly.
State management (Riverpod)
- App/shared state lives in generated providers; UI reads it via
ConsumerWidget/ConsumerStatefulWidget. Never usesetStatefor anything read by more than one widget or that outlives the widget. - Declare providers with
@riverpod. Derived read-only values are functions; mutable state is aNotifier/AsyncNotifierclass. In Riverpod 3 the ref parameter is the unifiedRef.
@riverpod
Future<User> user(Ref ref, {required String id}) =>
ref.watch(userRepositoryProvider).fetch(id);
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++; // no setState, no ref in the notifier body
}
ref.watchinsidebuildto react;ref.readinside callbacks/handlers only;ref.listenfor side effects (snackbars, navigation). Neverref.reada provider insidebuildto dodge rebuilds — that reads stale data.- Generated providers auto-dispose. Add
@Riverpod(keepAlive: true)only for genuinely global singletons (auth, config). Useref.keepAlive()for conditional caching. - Inject dependencies by overriding providers in
ProviderScope(overrides: [...])— repositories, clients, and env are providers, not global singletons or service locators. - Keep business logic in Notifiers, repositories, and services. A widget's job is to read state and dispatch intent; no HTTP calls, DB access, or branching business rules in
build.
Immutability and data modeling
- All models are immutable:
finalfields,constconstructors. Use Freezed for data classes withcopyWith/equality/JSON.
@freezed
sealed class Todo with _$Todo {
const factory Todo({required String id, required String title, @Default(false) bool done}) = _Todo;
factory Todo.fromJson(Map<String, Object?> json) => _$TodoFromJson(json);
}
- Model finite UI/domain variants as sealed classes or Freezed unions, then handle them with exhaustive
switchexpressions — the compiler forces you to cover every case. Prefer sealed types over nullable-field soup orenum + Object? data. - Use records for lightweight multi-value returns and destructuring instead of throwaway classes:
(int count, String label) stats() => (n, 'items');thenfinal (:count, :label) = stats();. - Use
copyWithto derive state; never mutate a field in place. Immutable collections: return new lists, or useunmodifiable/built_collection; don't expose a mutableListyou later mutate.
Async and error handling
- Model async with
AsyncValue<T>from anAsyncNotifier/future provider. Render all three states — never show a spinner-forever or swallow errors.
final todos = ref.watch(todoListProvider);
return switch (todos) {
AsyncData(:final value) => TodoListView(todos: value),
AsyncError(:final error) => ErrorView(message: '$error'),
AsyncLoading() => const Center(child: CircularProgressIndicator.adaptive()),
};
- In Notifier mutations, wrap work in
AsyncValue.guardso errors land instateinstead of escaping; setstate = const AsyncLoading()first when the UI should show progress. - When using a raw
Future/Stream, useFutureBuilder/StreamBuilderand branch onsnapshot.hasError,hasData, andconnectionState— but prefer Riverpod, which caches and cancels for you. Never ignoresnapshot.hasError. - Do not
awaitacross anasyncgap and then usecontextwithout anif (!context.mounted) return;guard (use_build_context_synchronouslyis an error). - Cancel in-flight work:
dioCancelToken,StreamSubscription.cancel()indispose. Don't leak timers or subscriptions.
Navigation (go_router)
- Single
GoRouterbehind a Riverpod provider. Use typed routes (go_router_builder) so params are compile-checked — no stringly-typedcontext.go('/user/$id').
@TypedGoRoute<HomeRoute>(path: '/', routes: [TypedGoRoute<UserRoute>(path: 'user/:id')])
class HomeRoute extends GoRouteData with _$HomeRoute {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const HomePage();
}
// navigate: const UserRoute(id: '42').go(context);
- Auth gating goes in the router-level
redirectwithrefreshListenabletied to the auth provider (aValueNotifier/GoRouterRefreshStream). Do not scatterNavigator.pushguards across screens. - Persistent bottom-nav / tabbed shells use
StatefulShellRoute.indexedStackto preserve each branch's state. UseerrorBuilderfor 404s. - Prefer
.go(declarative, replaces stack) over.pushunless you specifically want a poppable modal-style route. Avoid the imperativeNavigator1.0 API for app-level navigation.
Null safety and layout
- Sound null safety is on; never use
!without a proof the value is non-null on the line above. Prefer?.,??,??=,if (x case final v?), and pattern-matched early returns.lateonly when initialization is guaranteed before first read. - Respect constraints: a widget cannot be bigger than its parent allows. Wrap flex children in
Expanded/Flexible; never put an unbounded-heightColumninside another unbounded scrollable. UseLayoutBuilder/MediaQueryfor responsive branches andSafeAreafor notches. - Fix "unbounded constraints"/overflow by giving the offending axis a bound (
Expanded,SizedBox,shrinkWrap), not by hardcoding pixel sizes that break on other screens.
Testing
- Framework:
flutter_testfor unit + widget tests;integration_test(bundled) for full-app flows on device/emulator. Mock withmocktail. - Unit-test Notifiers/repositories by constructing a
ProviderContainer(overrides: [...]), reading providers, invoking methods, and asserting oncontainer.read(provider);addTearDown(container.dispose). - Widget-test UI by pumping inside a
ProviderScope(overrides: [...]), thenfind/tester.tap/pumpAndSettleand assert on rendered state. Override providers to inject fakes — do not hit the network. - Cover, at minimum: each
AsyncValuestate (loading/error/data) renders correctly, form validation, and navigation redirects. Usegoldentests for critical pixel-stable UI. - Prefer keying finders by semantics/
ValueKeyover brittle text matching. Keep tests deterministic — pump explicit frames, no real timers.
Security
- Never hardcode API keys, tokens, or secrets in Dart source or commit them — Flutter ships your Dart as reversible bytecode. Inject config via
--dart-define-from-file=config.jsonand read withString.fromEnvironment. - Store tokens/credentials in
flutter_secure_storage(Keychain / EncryptedSharedPreferences), never inSharedPreferencesor plain files. - Enforce HTTPS/TLS; for high-value apps add certificate pinning via a
diointerceptor. Validate and encode all server input; treat deep-link/GoRouterStateparams as untrusted. - Ship release builds obfuscated:
flutter build <target> --obfuscate --split-debug-info=build/symbols. Never log tokens, PII, or full request bodies; strip debugprint/verbose logging from release. - Keep dependencies patched:
flutter pub outdatedfor staleness, and rely on pub's built-in security advisories (surfaced ondart pub get) plus an OSV-based scanner (osv-scanner, or thedart_audit/dep_auditpackages) in CI — there is nodart pub auditcommand. Pin versions and review every transitive addition.
Do
- Compose small const widget classes; give each a
constconstructor andsuper.key. - Keep logic in Notifiers/repositories/services; widgets only read state and dispatch intent.
- Model finite states as sealed/Freezed types and consume them with exhaustive
switch. - Handle loading/error/data for every async source; wrap mutations in
AsyncValue.guard. - Guard
contextafterawaitwithcontext.mounted; dispose every controller/subscription. - Use typed
go_routerroutes and centralizedredirectfor auth. - Inject dependencies through provider overrides; test via
ProviderContainer/ProviderScope.
Avoid
setStatefor shared/app state → use Riverpod providers.setStateis only for local ephemeral UI.Widget _buildX()helper methods → extract a widget class so rebuilds isolate andconstapplies.- Missing
conston constructors/literals → keepprefer_const_constructorsclean. !/ force-unwrap without proof, andlateused to dodge nullability → pattern-match or provide defaults.- Freezed
.when/.map(removed in 3.x) andmockitocodegen → nativeswitchpatterns andmocktail. - Stringly-typed
context.go('/path/$id')and rawNavigator.pushfor app nav → typedgo_routerroutes. - Business logic, HTTP, or DB calls inside
build; giant 200-line build methods; mapping lists intoColumninstead ofListView.builder. - Global singletons / service locators for dependencies → provider overrides. Secrets in source or
SharedPreferences.
When you code
- Make small, focused diffs scoped to one feature. Touch generated
*.g.dart/*.freezed.dartonly by rerunningbuild_runner, never by hand. - After any change: run
dart run build_runner build -dif annotations changed, thendart format .,flutter analyze --fatal-infos, and the relevantflutter test. Do not report done with analyzer warnings outstanding. - When adding a package, pin a current version in
pubspec.yamland runflutter pub get; prefer a Riverpod-friendly, actively maintained option over an abandoned one. - Ask before: introducing a second state-management library, changing the routing/DI architecture, adding native platform code or a heavy plugin, or bumping the Flutter/Dart SDK constraint. Otherwise follow the conventions above without prompting.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Dart 3.12 · Flutter 3.44 · Riverpod 3.