一键导入
flutter-idioms
Flutter Riverpod 3, freezed, go_router, const widgets, repository pattern.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Flutter Riverpod 3, freezed, go_router, const widgets, repository pattern.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
| name | flutter-idioms |
| description | Flutter Riverpod 3, freezed, go_router, const widgets, repository pattern. |
| paths | ["**/*.dart","**/pubspec.yaml","**/analysis_options.yaml"] |
Flutter is a UI toolkit first — performance is a first-class concern. const widgets and immutable data keep the render tree efficient. Riverpod 3 is the canonical state management solution: compile-safe, testable without BuildContext, no implicit global state, and with automatic retry and pause/resume built in.
Code generation is mandatory. All providers must use @riverpod / @Riverpod(keepAlive: true) annotations with riverpod_generator and build_runner. This catches type errors, missing overrides, and broken provider graphs at compile time — bugs are caught before they reach users.
Required dependencies:
# pubspec.yaml
dependencies:
flutter_riverpod: 3.2.1
riverpod_annotation: 4.0.2
dev_dependencies:
riverpod_generator: 4.0.3
build_runner: # latest
riverpod_lint: # latest
Scope: This file covers Flutter/Dart coding idioms. For file and folder layout, see
references/project-structure.md. For test naming, seetesting-strategy.md. For general error handling principles, seeerror-handling-principles.md.
const Constructors — EverywhereMake every widget const when possible. const widgets are created once and never rebuilt unless their inputs change — this is Flutter's most impactful performance optimization.
// ✅ const constructor — widget is rebuild-safe
class TaskCard extends StatelessWidget {
const TaskCard({super.key, required this.task});
final Task task;
// ...
}
// Usage — compile-time constant
const TaskCard(task: myTask)
// ❌ Missing const — rebuilt on every parent rebuild
TaskCard(task: myTask)
Rules:
StatelessWidget that has no mutable state must have a const constructorconst keyword at the call site, not just the definitionprefer_const_constructors must be enabled in analysis_options.yamlLarge build methods are the primary source of performance problems and unmaintainable UI code.
Extract a new widget when a subtree has distinct responsibilities
// ❌ Everything in one build method
@override
Widget build(BuildContext context) {
return Column(children: [
// 30 lines of header...
// 50 lines of list...
// 20 lines of footer...
]);
}
// ✅ Each subtree is a named widget with a const constructor
@override
Widget build(BuildContext context) {
return Column(children: [
const TaskHeader(),
const TaskList(),
const TaskFooter(),
]);
}
Never use builder methods (_buildHeader()) as a substitute for extracting widgets
const and always rerun on parent rebuildStatelessWidget or ConsumerWidget insteadKeep build methods under ~30 lines — if longer, decompose
freezedAll domain models must be immutable. Use the freezed package for:
copyWith==, hashCode, and toString// task/models/task.dart
@freezed
class Task with _$Task {
const factory Task({
required String id,
required String title,
@Default(TaskStatus.pending) TaskStatus status,
DateTime? dueDate,
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
// Usage — immutable update via copyWith
final updated = task.copyWith(status: TaskStatus.done);
// ❌ Never mutate a model directly
task.status = TaskStatus.done; // compile error — field is final
Rules:
@freezeddart run build_runner build after changing freezed modelsUse this tree to pick the correct provider pattern. Follow it top-to-bottom — the first match wins.
Does the provider have side-effects (create/update/delete)? ──> YES ──┐
│ │
NO │
│ Is it async?
│ │ │
Is it async? YES NO
│ │ │ │
YES NO ▼ ▼
│ │ @riverpod @riverpod
▼ ▼ class class
@riverpod @riverpod (AsyncNotifier) (Notifier)
function function
(FutureProvider) (Provider)
Decision summary:
| Has side-effects? | Async? | Pattern | Provider type generated |
|---|---|---|---|
| No | No | @riverpod function | Provider |
| No | Yes | @riverpod async function | FutureProvider |
| No | Stream | @riverpod Stream function | StreamProvider |
| Yes | No | @riverpod class | NotifierProvider |
| Yes | Yes | @riverpod class with Future build | AsyncNotifierProvider |
| Yes | Stream | @riverpod class with Stream build | StreamNotifierProvider |
Riverpod 3 is the only state management solution used in this project. Do not introduce BLoC, Cubit, Provider (package:provider), or GetX.
For file layout of state/ directories, see
references/project-structure.md.
ProviderScopeEvery Flutter app using Riverpod must wrap the root widget in ProviderScope. This creates the ProviderContainer that powers all providers in the widget tree.
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Rules:
ProviderScope at the app root — never nest ProviderScope widgetsoverrides: parameter only in tests (see Testing section)retry: to the root ProviderScope (see Runtime Behaviors)Use class-based providers when the provider needs methods to modify state.
// Synchronous notifier — e.g., a filter or toggle
@riverpod
class TaskFilter extends _$TaskFilter {
@override
TaskFilterState build() {
return const TaskFilterState();
}
void setStatus(TaskStatus? status) {
state = state.copyWith(status: status);
}
void toggleShowCompleted() {
state = state.copyWith(showCompleted: !state.showCompleted);
}
}
// Async notifier — e.g., CRUD operations
@riverpod
class TaskList extends _$TaskList {
@override
Future<List<Task>> build() async {
return ref.watch(taskRepositoryProvider).getTasks();
}
Future<void> addTask(CreateTaskRequest request) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repo = ref.read(taskRepositoryProvider);
await repo.createTask(request);
// REQUIRED: check ref.mounted after every await
if (!ref.mounted) return state.requireValue;
return repo.getTasks();
});
}
Future<void> deleteTask(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repo = ref.read(taskRepositoryProvider);
await repo.deleteTask(id);
if (!ref.mounted) return state.requireValue;
return repo.getTasks();
});
}
}
Use functional providers for derived values with no side-effects.
// Computed value — filtered task list
@riverpod
List<Task> filteredTasks(Ref ref) {
final tasks = ref.watch(taskListProvider).valueOrNull ?? [];
final filter = ref.watch(taskFilterProvider);
return tasks.where((t) => filter.matches(t)).toList();
}
// Async one-shot read — e.g., fetch a single task
@riverpod
Future<Task> taskDetail(Ref ref, String id) async {
return ref.watch(taskRepositoryProvider).getById(id);
}
// Stream — real-time data
@riverpod
Stream<List<Task>> taskStream(Ref ref) {
return ref.watch(taskRepositoryProvider).watchAll();
}
Family providers (parameterized): When a provider takes extra arguments beyond Ref, Riverpod creates a family — each unique argument combination gets its own independent provider instance with its own cache and disposal. With code generation (which we mandate), any number of parameters are supported, including named, optional, and default values. Constraints:
== and hashCode (primitives, freezed models, Dart records all work)// ✅ Family with a single key
@riverpod
Future<Task> taskDetail(Ref ref, String id) async {
return ref.watch(taskRepositoryProvider).getById(id);
}
// ✅ Multiple parameters — fully supported with code generation
@riverpod
Future<List<Task>> projectTasks(Ref ref, String projectId, {TaskStatus? status}) async {
return ref.watch(taskRepositoryProvider).getByProject(projectId, status: status);
}
// Usage: ref.watch(projectTasksProvider('proj-1', status: TaskStatus.done))
ref.watch vs ref.read// ✅ ref.watch — subscribes to changes, use inside build() or widget build
final tasks = ref.watch(taskListProvider);
// ✅ ref.read — one-time read, use inside event handlers / notifier actions
Future<void> onSubmit() async {
await ref.read(taskListProvider.notifier).addTask(request);
}
// ❌ Never use ref.watch inside async functions or event handlers
Future<void> onSubmit() async {
final tasks = ref.watch(taskListProvider); // WRONG — causes errors
}
Ref.mounted — Mandatory After AwaitsRiverpod 3 throws if you interact with a disposed Ref or Notifier. Always check ref.mounted after any await in a notifier.
Future<void> updateTask(Task task) async {
final repo = ref.read(taskRepositoryProvider);
await repo.update(task);
// REQUIRED: provider may have been disposed during await
if (!ref.mounted) return;
state = await AsyncValue.guard(() => repo.getTasks());
}
keepAlive// ✅ autoDispose is the DEFAULT with code generation (@riverpod)
// Provider is disposed when no consumers are listening
@riverpod
Future<Task> taskDetail(Ref ref, String id) async {
return ref.watch(taskRepositoryProvider).getById(id);
}
// ✅ Opt into keepAlive explicitly for app-wide, long-lived state
@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
@override
Future<User?> build() async {
return ref.watch(authRepositoryProvider).getCurrentUser();
}
}
// ✅ Repositories should also be keepAlive — they hold connection state and
// should not be re-initialized every time a screen rebuilds
@Riverpod(keepAlive: true)
TaskRepository taskRepository(Ref ref) {
return TaskRepositoryImpl(apiClient: ref.watch(apiClientProvider));
}
// ❌ Do not set keepAlive: false — that is the default
// ❌ Do not set keepAlive: true for screen-scoped state
All data access goes through an abstract repository interface. This is the Flutter expression of the Testability-First architecture (see @.agents/rules/architectural-pattern.md).
// repository/task_repository.dart — Abstract interface (contract)
abstract class TaskRepository {
Future<List<Task>> getTasks();
Future<Task> getById(String id);
Future<void> createTask(CreateTaskRequest request);
Future<void> deleteTask(String id);
}
// repository/task_repository_impl.dart — Production adapter
class TaskRepositoryImpl implements TaskRepository {
const TaskRepositoryImpl({required this.apiClient});
final ApiClient apiClient;
@override
Future<List<Task>> getTasks() async {
final response = await apiClient.get('/tasks');
return (response.data as List)
.map((e) => Task.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<Task> getById(String id) async {
final response = await apiClient.get('/tasks/$id');
return Task.fromJson(response.data as Map<String, dynamic>);
}
@override
Future<void> createTask(CreateTaskRequest request) async {
await apiClient.post('/tasks', data: request.toJson());
}
@override
Future<void> deleteTask(String id) async {
await apiClient.delete('/tasks/$id');
}
}
// repository/task_repository_mock.dart — Test adapter
class MockTaskRepository implements TaskRepository {
final List<Task> _tasks = [];
@override
Future<List<Task>> getTasks() async => List.unmodifiable(_tasks);
@override
Future<Task> getById(String id) async =>
_tasks.firstWhere((t) => t.id == id);
@override
Future<void> createTask(CreateTaskRequest request) async {
_tasks.add(Task(id: 'mock-id', title: request.title));
}
@override
Future<void> deleteTask(String id) async {
_tasks.removeWhere((t) => t.id == id);
}
}
// Wiring — provider that the rest of the app depends on
@Riverpod(keepAlive: true)
TaskRepository taskRepository(Ref ref) {
return TaskRepositoryImpl(apiClient: ref.watch(apiClientProvider));
}
// In tests, override with mock:
// taskRepositoryProvider.overrideWith((_) => MockTaskRepository())
// ✅ Prefer ConsumerWidget — stateless, simpler
class TaskListView extends ConsumerWidget {
const TaskListView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncTasks = ref.watch(taskListProvider);
return asyncTasks.when(
data: (tasks) => TaskListBody(tasks: tasks),
loading: () => const LoadingIndicator(),
error: (e, _) => ErrorView(error: e),
);
}
}
// Use ConsumerStatefulWidget only when local widget state + riverpod is needed
Riverpod 3 automatically retries providers that throw, using exponential backoff. This improves resilience against transient network failures.
// ✅ Default behavior — providers auto-retry on failure
// No action needed for standard use
// ✅ Disable retry for a specific provider when failure is non-transient
@Riverpod(retry: null)
Future<Config> appConfig(Ref ref) async {
return ref.watch(configRepositoryProvider).load();
}
// ✅ Disable globally via ProviderScope
ProviderScope(
retry: (_, __) => null, // disable for all providers
child: const MyApp(),
)
Rules:
Riverpod 3 automatically pauses provider listeners when the consuming widget is no longer visible. When the widget becomes visible again, listeners resume.
This is automatic — no action needed. Be aware of it when debugging.
ProviderException WrappingWhen a provider fails, reading it in Riverpod 3 throws a ProviderException wrapping the original error, not the raw exception.
// ✅ Catch ProviderException when reading providers that may fail
try {
final value = container.read(myProvider);
} on ProviderException catch (e) {
// e.exception contains the original error
// e.provider contains which provider failed
}
// ✅ Assert on provider failure in tests
expect(
() => container.read(myProvider),
throwsA(isA<ProviderException>()),
);
==)Riverpod 3 uses the == operator (not identical) to determine if state changed and rebuilds are needed. This means:
freezed models work correctly out of the box (generated ==)== / hashCode or use freezedupdateShouldNotify on a Notifier for custom comparison logicAlways handle all three AsyncValue states: data, loading, error
// ✅ Exhaustive
asyncValue.when(
data: (data) => DataWidget(data: data),
loading: () => const CircularProgressIndicator(),
error: (err, stack) => ErrorText(err.toString()),
);
Surfacing notifier errors to UI — use when(error:...) for exhaustive handling; use hasError only for conditional checks alongside a separate data display:
// ✅ Exhaustive — covers all states, preferred for full-screen states
asyncTasks.when(
data: (tasks) => TaskListBody(tasks: tasks),
loading: () => const LoadingIndicator(),
error: (e, _) => ErrorView(error: e),
);
// ✅ Conditional — show inline error banner while keeping stale data visible
if (asyncTasks.hasError) {
// show snackbar or inline error
}
final tasks = asyncTasks.valueOrNull ?? const [];
Use safe AsyncValue accessors to prevent runtime crashes
// ✅ Safe — returns null if state is loading or error
final tasks = ref.watch(taskListProvider).valueOrNull;
// ⚠️ Unsafe — throws StateError if state is not AsyncData
// Only use when you have already confirmed the state is loaded
final tasks = ref.watch(taskListProvider).requireValue;
Use AsyncValue.guard inside notifier actions to wrap async calls
AsyncError automaticallyUse StreamProvider for real-time data — never poll manually with Timer
Always check ref.mounted after await — see Ref.mounted section above
Force a provider to re-fetch with ref.invalidate
// ✅ Invalidate from outside a notifier (e.g., after a form submit in a widget)
ref.invalidate(taskListProvider);
// The next watch/read will trigger a fresh build()
// ✅ Invalidate from inside a notifier after a mutation
Future<void> addTask(CreateTaskRequest request) async {
final repo = ref.read(taskRepositoryProvider);
await repo.createTask(request);
if (!ref.mounted) return;
ref.invalidateSelf(); // triggers build() to re-run and return fresh list
}
Decision: invalidateSelf() vs manual state assignment
After a mutation:
├── Need optimistic UI (instant visual update before server confirms)?
│ └── YES → Set state manually via AsyncValue.guard
│ (e.g., remove item from list immediately, then call API)
└── NO → ref.invalidateSelf() (simpler, always correct — re-runs build())
Define a typed exception hierarchy using sealed classes (Dart 3+). Map infrastructure exceptions to domain exceptions inside notifier actions.
// core/errors/app_exception.dart — Typed exception hierarchy
sealed class AppException implements Exception {
const AppException(this.message);
final String message;
@override
String toString() => message;
}
class NetworkException extends AppException {
const NetworkException(super.message, {this.statusCode});
final int? statusCode;
}
class ValidationException extends AppException {
const ValidationException(super.message, {required this.field});
final String field;
}
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
// ✅ Map infrastructure exceptions to domain exceptions inside notifier actions
Future<void> addTask(CreateTaskRequest request) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
try {
final repo = ref.read(taskRepositoryProvider);
await repo.createTask(request);
if (!ref.mounted) return state.requireValue;
return repo.getTasks();
} on DioException catch (e) {
// Transform infrastructure error → domain error
throw NetworkException(
'Failed to create task: ${e.message}',
statusCode: e.response?.statusCode,
);
}
});
}
// ✅ Handle typed errors in UI
asyncTasks.when(
data: (tasks) => TaskListBody(tasks: tasks),
loading: () => const LoadingIndicator(),
error: (error, _) => switch (error) {
NetworkException() => ErrorView(message: 'Network error: ${error.message}'),
NotFoundException() => const ErrorView(message: 'Not found'),
_ => ErrorView(message: 'Unexpected error: $error'),
},
);
Rules:
AppException (sealed class)DioException, SocketException) are caught and re-thrown as domain exceptionsswitch on the sealed class in error displayerror-handling-principles.md for general error handling guidancego_routergo_router is the canonical navigation library.
// core/router/app_router.dart
@riverpod
GoRouter appRouter(Ref ref) {
return GoRouter(
initialLocation: '/tasks',
routes: [
GoRoute(path: '/tasks', builder: (_, __) => const TaskListView()),
GoRoute(
path: '/tasks/:id',
builder: (_, state) => TaskDetailView(
// state.pathParameters['id'] is guaranteed non-null by the :id
// route pattern — acceptable use of ! in route infrastructure code
id: state.pathParameters['id']!,
),
),
],
);
}
// Navigate — always by path, never by widget reference
context.go('/tasks/$taskId');
context.push('/tasks/new'); // push adds to the back stack
Null safety — use ?., ??, and ??= idiomatically
final city = user?.address?.city ?? 'Unknown';
cache ??= await compute(); // assign only if null
Use late only for fields initialized before first use that cannot be final
final fields initialized in the constructorlate without initialization is an unsafe nullable escape hatchExtension methods for adding behaviour to types you don't own
extension TaskStatusLabel on TaskStatus {
String get label => switch (this) {
TaskStatus.pending => 'Pending',
TaskStatus.done => 'Done',
};
}
Use switch expressions (Dart 3+) for exhaustive pattern matching
final label = status switch {
TaskStatus.pending => 'Pending',
TaskStatus.done => 'Done',
// Compiler error if a case is missing
};
Avoid dynamic — it is the Dart equivalent of TypeScript's any
Test naming and pyramid proportions are defined in
testing-strategy.md. This section covers Flutter/Riverpod 3 test patterns.
ProviderContainer.testProviderContainer.test creates an isolated container that auto-disposes after the test — no manual addTearDown needed.
test('addTask updates state', () async {
final container = ProviderContainer.test(overrides: [
taskRepositoryProvider.overrideWith((_) => MockTaskRepository()),
]);
await container.read(taskListProvider.notifier).addTask(request);
expect(container.read(taskListProvider).value, hasLength(1));
});
build Method with overrideWithBuildWhen you need to control initial state but keep the notifier's methods intact:
test('deleteTask removes item from pre-seeded list', () async {
final container = ProviderContainer.test(overrides: [
taskRepositoryProvider.overrideWith((_) => MockTaskRepository()),
taskListProvider.overrideWithBuild((ref, notifier) {
// Seed initial state — methods (addTask, deleteTask) still work
return Future.value([mockTask1, mockTask2]);
}),
]);
await container.read(taskListProvider.notifier).deleteTask(mockTask1.id);
expect(container.read(taskListProvider).value, hasLength(1));
});
ProviderScopetestWidgets('shows task list', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
taskRepositoryProvider.overrideWith((_) => MockTaskRepository()),
],
child: const MaterialApp(home: TaskListView()),
));
expect(find.byType(TaskCard), findsWidgets);
});
mockito with @GenerateNiceMocks for Interface MocksPlace the annotation on the library (top of the test file or on main) and add a part directive for the generated file:
// task_notifier_test.dart
@GenerateNiceMocks([MockSpec<TaskRepository>()])
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'task_notifier_test.mocks.dart'; // generated by build_runner
void main() {
// tests use MockTaskRepository()
}
These are common mistakes agents reproduce from Riverpod 2 training data. Every item below is a hard rule violation.
| ❌ Anti-Pattern | ✅ Correct Pattern |
|---|---|
StateProvider | @riverpod class with Notifier |
StateNotifierProvider | @riverpod class with Notifier |
ChangeNotifierProvider | @riverpod class with Notifier |
import 'package:riverpod/legacy.dart' | Never import legacy APIs |
Typed ref subclass (TaskDetailRef ref) | Ref ref — Riverpod 3 uses a single Ref type; typed subclasses were a Riverpod 2 codegen artifact that no longer exists |
ref.watch inside async/event handler | ref.read for one-shot reads in handlers |
Accessing state/ref after await without ref.mounted check | Always check ref.mounted after await |
ProviderContainer() + addTearDown(container.dispose) | ProviderContainer.test(overrides: [...]) |
Timer.periodic for polling data | StreamProvider or Stream return |
keepAlive: false in annotation | Omit — false is the default |
Manual providers without @riverpod annotation | Always use code generation |
| Catching raw exceptions from provider reads | Catch ProviderException |
overrideWith((_) => MockNotifier()) when only initial state needs seeding | overrideWithBuild(...) — keeps notifier methods intact, only seeds initial state |
| Tool | Purpose | Config File |
|---|---|---|
dart format | Canonical formatting | — (built-in) |
flutter analyze | Static analysis + lint | analysis_options.yaml |
riverpod_lint | Riverpod-specific lint rules | dev_dependencies |
dart pub deps | Dependency audit | — |
dart run build_runner build | Generate provider code | — |
Mandatory analysis_options.yaml settings (Dart 3+):
analyzer:
language:
strict-casts: true
strict-raw-types: true
errors:
invalid_assignment: error
plugins:
- riverpod_lint
linter:
rules:
- prefer_const_constructors
- prefer_const_declarations
- avoid_dynamic_calls
- avoid_print
- use_super_parameters
After any code change involving providers, run:
dart run build_runner build --delete-conflicting-outputs
flutter analyze
dart format .
During active development, use watch mode to avoid re-running manually:
dart run build_runner watch --delete-conflicting-outputs
Structured fault tolerance for coordinator agents. 5-level escalation ladder (Retry → Replace → Skip → Redistribute → Degrade), dead-man timers, degraded completion protocol, and cross-level escalation format. Load when orchestrating agents that may fail.
Structured code review protocol for inspecting code quality against the full rule set. Use when auditing code written by yourself or another agent, during the /audit workflow, or when the user asks for a code review.
Reusable convergence protocol for coordinator agents. Defines the BRIEFING → ITERATE → GATE → CONVERGE loop, context hygiene rules, self-succession protocol, turn budget, and handoff compression. Load when orchestrating multi-iteration workflows.
Pre-flight checklist and post-implementation self-review protocol. Use before generating any code (pre-flight) and after writing code but before verification (self-review) to catch issues early.
MECE task decomposition, file ownership enforcement, DAG-based execution, and safe merge protocol for intra-domain parallel dispatch. The safety invariants that prevent merge chaos when multiple agents write in parallel. Applies recursively at every nesting depth.
Shared protocols for all agents in the multi-agent pipeline: recursive nesting, pre-implementation restatement, parallel dispatch format, and agent definition cascade. Load this skill instead of inlining these protocols in every agent file.