Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Mobile · Dart 3.12 · Flutter 3.44 · Riverpod 3

Flutter

Composition, immutable state and Riverpod — idiomatic Flutter.

flutterdartmobileriverpod

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 switch expressions, 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.2 with code generation — riverpod_annotation: ^3.x, riverpod_generator: ^4.0.4, custom_lint + riverpod_lint. Riverpod 3 unifies Ref (no more per-provider FooRef), makes generated providers auto-dispose by default, and adds automatic retry, provider pause when unwatched, and typed mutations.
  • Navigation: go_router: ^17.3.0 with typed routes via go_router_builder: ^4.3.0 (@TypedGoRoute).
  • Immutable models / unions: freezed: ^3.2.5 + freezed_annotation: ^3.x; JSON via json_serializable + json_annotation. Freezed 3 requires sealed/abstract classes and removed .when/.map — use native Dart pattern matching.
  • Codegen: build_runner: ^2.15.0. Run dart run build_runner watch -d during development.
  • HTTP: dio: ^5.x (interceptors, cancel tokens) or http for 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_ui packages (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 on package:flutter/material.dart for production; the eventual swap to package:material_ui/material_ui.dart is a mechanical import change.

Project conventions

  • Feature-first layout, not layer-first: lib/src/features/<feature>/{data,domain,presentation}/, plus lib/src/common/ (shared widgets), lib/src/routing/, lib/src/core/ (env, theme, error). Keep main.dart thin.
  • 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. Enable always_use_package_imports or prefer_relative_imports and stay consistent.
  • analysis_options.yaml: include: package:very_good_analysis/analysis_options.yaml; add errors: invalid_annotation_target: ignore for Freezed. Exclude **/*.g.dart and **/*.freezed.dart from analysis.
  • Format with dart format . (trailing-comma-driven wrapping). Fix mechanically with dart fix --apply. CI gate: dart format --set-exit-if-changed . + flutter analyze --fatal-infos.

Widgets and composition

  • Extract subtrees into StatelessWidget/ConsumerWidget classes, never Widget _buildHeader() helper methods. Widget classes get their own element, rebuild in isolation, and can be const; helper methods rebuild with the whole parent and defeat const.
// 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 const on every constructor and every widget literal that allows it (prefer_const_constructors is an error). A const widget is skipped during rebuilds. Give every public widget a const constructor and super.key.
  • Cap build methods at roughly one screenful. A method past ~40 lines or nesting past ~4 widgets deep must be split into child widget classes.
  • Use StatefulWidget only for local ephemeral UI — animation controllers, TextEditingController, focus, scroll position, page-view index. Dispose every controller in dispose().
  • Prefer ListView.builder/SliverList over mapping a list into a Column; never build unbounded scrollable content eagerly.

State management (Riverpod)

  • App/shared state lives in generated providers; UI reads it via ConsumerWidget/ConsumerStatefulWidget. Never use setState for 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 a Notifier/AsyncNotifier class. In Riverpod 3 the ref parameter is the unified Ref.
@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.watch inside build to react; ref.read inside callbacks/handlers only; ref.listen for side effects (snackbars, navigation). Never ref.read a provider inside build to dodge rebuilds — that reads stale data.
  • Generated providers auto-dispose. Add @Riverpod(keepAlive: true) only for genuinely global singletons (auth, config). Use ref.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: final fields, const constructors. Use Freezed for data classes with copyWith/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 switch expressions — the compiler forces you to cover every case. Prefer sealed types over nullable-field soup or enum + Object? data.
  • Use records for lightweight multi-value returns and destructuring instead of throwaway classes: (int count, String label) stats() => (n, 'items'); then final (:count, :label) = stats();.
  • Use copyWith to derive state; never mutate a field in place. Immutable collections: return new lists, or use unmodifiable/built_collection; don't expose a mutable List you later mutate.

Async and error handling

  • Model async with AsyncValue<T> from an AsyncNotifier/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.guard so errors land in state instead of escaping; set state = const AsyncLoading() first when the UI should show progress.
  • When using a raw Future/Stream, use FutureBuilder/StreamBuilder and branch on snapshot.hasError, hasData, and connectionState — but prefer Riverpod, which caches and cancels for you. Never ignore snapshot.hasError.
  • Do not await across an async gap and then use context without an if (!context.mounted) return; guard (use_build_context_synchronously is an error).
  • Cancel in-flight work: dio CancelToken, StreamSubscription.cancel() in dispose. Don't leak timers or subscriptions.
  • Single GoRouter behind a Riverpod provider. Use typed routes (go_router_builder) so params are compile-checked — no stringly-typed context.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 redirect with refreshListenable tied to the auth provider (a ValueNotifier/GoRouterRefreshStream). Do not scatter Navigator.push guards across screens.
  • Persistent bottom-nav / tabbed shells use StatefulShellRoute.indexedStack to preserve each branch's state. Use errorBuilder for 404s.
  • Prefer .go (declarative, replaces stack) over .push unless you specifically want a poppable modal-style route. Avoid the imperative Navigator 1.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. late only 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-height Column inside another unbounded scrollable. Use LayoutBuilder/MediaQuery for responsive branches and SafeArea for 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_test for unit + widget tests; integration_test (bundled) for full-app flows on device/emulator. Mock with mocktail.
  • Unit-test Notifiers/repositories by constructing a ProviderContainer(overrides: [...]), reading providers, invoking methods, and asserting on container.read(provider); addTearDown(container.dispose).
  • Widget-test UI by pumping inside a ProviderScope(overrides: [...]), then find/tester.tap/pumpAndSettle and assert on rendered state. Override providers to inject fakes — do not hit the network.
  • Cover, at minimum: each AsyncValue state (loading/error/data) renders correctly, form validation, and navigation redirects. Use golden tests for critical pixel-stable UI.
  • Prefer keying finders by semantics/ValueKey over 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.json and read with String.fromEnvironment.
  • Store tokens/credentials in flutter_secure_storage (Keychain / EncryptedSharedPreferences), never in SharedPreferences or plain files.
  • Enforce HTTPS/TLS; for high-value apps add certificate pinning via a dio interceptor. Validate and encode all server input; treat deep-link/GoRouterState params as untrusted.
  • Ship release builds obfuscated: flutter build <target> --obfuscate --split-debug-info=build/symbols. Never log tokens, PII, or full request bodies; strip debug print/verbose logging from release.
  • Keep dependencies patched: flutter pub outdated for staleness, and rely on pub's built-in security advisories (surfaced on dart pub get) plus an OSV-based scanner (osv-scanner, or the dart_audit/dep_audit packages) in CI — there is no dart pub audit command. Pin versions and review every transitive addition.

Do

  • Compose small const widget classes; give each a const constructor and super.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 context after await with context.mounted; dispose every controller/subscription.
  • Use typed go_router routes and centralized redirect for auth.
  • Inject dependencies through provider overrides; test via ProviderContainer/ProviderScope.

Avoid

  • setState for shared/app state → use Riverpod providers. setState is only for local ephemeral UI.
  • Widget _buildX() helper methods → extract a widget class so rebuilds isolate and const applies.
  • Missing const on constructors/literals → keep prefer_const_constructors clean.
  • ! / force-unwrap without proof, and late used to dodge nullability → pattern-match or provide defaults.
  • Freezed .when/.map (removed in 3.x) and mockito codegen → native switch patterns and mocktail.
  • Stringly-typed context.go('/path/$id') and raw Navigator.push for app nav → typed go_router routes.
  • Business logic, HTTP, or DB calls inside build; giant 200-line build methods; mapping lists into Column instead of ListView.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.dart only by rerunning build_runner, never by hand.
  • After any change: run dart run build_runner build -d if annotations changed, then dart format ., flutter analyze --fatal-infos, and the relevant flutter test. Do not report done with analyzer warnings outstanding.
  • When adding a package, pin a current version in pubspec.yaml and run flutter 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.

Back to top ↑