[autoresearch] baseline: system prompt quality tests (5/5 passing)
This commit is contained in:
1 parent
05cc036f6d
commit
60b0101717
142 files changed
+18589
-9947
No files matched your search
@@ -0,0 +1,871 @@
|
||||
---
|
||||
trigger: model_decision
|
||||
description: When writing Flutter or Dart code, working on mobile UI, using Riverpod for state management, or building Flutter widgets
|
||||
---
|
||||
|
||||
## Flutter Idioms and Patterns (Riverpod 3)
|
||||
|
||||
### Core Philosophy
|
||||
|
||||
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:**
|
||||
|
||||
```yaml
|
||||
# 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 `project-structure-flutter-mobile.md`. For test naming, see `testing-strategy.md`. For general error handling principles, see `error-handling-principles.md`.
|
||||
|
||||
---
|
||||
|
||||
### `const` Constructors — Everywhere
|
||||
|
||||
Make 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.
|
||||
|
||||
```dart
|
||||
// ✅ 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:**
|
||||
- Every `StatelessWidget` that has no mutable state must have a `const` constructor
|
||||
- Pass `const` keyword at the call site, not just the definition
|
||||
- Lint rule `prefer_const_constructors` must be enabled in `analysis_options.yaml`
|
||||
|
||||
---
|
||||
|
||||
### Widget Decomposition
|
||||
|
||||
Large `build` methods are the primary source of performance problems and unmaintainable UI code.
|
||||
|
||||
1. **Extract a new widget when a subtree has distinct responsibilities**
|
||||
```dart
|
||||
// ❌ 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(),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Never use builder methods (`_buildHeader()`) as a substitute for extracting widgets**
|
||||
- Builder methods do not benefit from `const` and always rerun on parent rebuild
|
||||
- Extract a proper `StatelessWidget` or `ConsumerWidget` instead
|
||||
|
||||
3. **Keep `build` methods under ~30 lines** — if longer, decompose
|
||||
|
||||
---
|
||||
|
||||
### Immutable Data with `freezed`
|
||||
|
||||
All domain models must be immutable. Use the `freezed` package for:
|
||||
- Immutable value objects with `copyWith`
|
||||
- Union/sealed types (loading, success, error states)
|
||||
- Generated `==`, `hashCode`, and `toString`
|
||||
|
||||
```dart
|
||||
// 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:**
|
||||
- All domain models use `@freezed`
|
||||
- Never expose mutable fields on domain models
|
||||
- Run `dart run build_runner build` after changing freezed models
|
||||
|
||||
---
|
||||
|
||||
### Provider Decision Tree
|
||||
|
||||
Use 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 — State Management
|
||||
|
||||
**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 `project-structure-flutter-mobile.md`.
|
||||
|
||||
#### App Entry Point — `ProviderScope`
|
||||
|
||||
Every Flutter app using Riverpod must wrap the root widget in `ProviderScope`. This creates the `ProviderContainer` that powers all providers in the widget tree.
|
||||
|
||||
```dart
|
||||
// main.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Exactly one `ProviderScope` at the app root — never nest `ProviderScope` widgets
|
||||
- Use `overrides:` parameter only in tests (see Testing section)
|
||||
- For retry configuration, pass `retry:` to the root `ProviderScope` (see Runtime Behaviors)
|
||||
|
||||
#### Provider Definition — Class-Based (Side-Effects)
|
||||
|
||||
Use class-based providers when the provider needs methods to modify state.
|
||||
|
||||
```dart
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Provider Definition — Functional (Read-Only / Computed)
|
||||
|
||||
Use functional providers for derived values with no side-effects.
|
||||
|
||||
```dart
|
||||
// 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:
|
||||
- All parameters must implement `==` and `hashCode` (primitives, `freezed` models, Dart records all work)
|
||||
- Each family member is disposed independently when no listener remains
|
||||
|
||||
```dart
|
||||
// ✅ 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`
|
||||
|
||||
```dart
|
||||
// ✅ 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 Awaits
|
||||
|
||||
Riverpod 3 throws if you interact with a disposed `Ref` or `Notifier`. **Always check `ref.mounted` after any `await` in a notifier.**
|
||||
|
||||
```dart
|
||||
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());
|
||||
}
|
||||
```
|
||||
|
||||
#### Auto-Dispose and `keepAlive`
|
||||
|
||||
```dart
|
||||
// ✅ 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
|
||||
```
|
||||
|
||||
#### Repository Interface Pattern
|
||||
|
||||
All data access goes through an abstract repository interface. This is the Flutter expression of the Testability-First architecture (see `architectural-pattern.md`).
|
||||
|
||||
```dart
|
||||
// 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);
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// 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())
|
||||
```
|
||||
|
||||
#### ConsumerWidget vs ConsumerStatefulWidget
|
||||
|
||||
```dart
|
||||
// ✅ 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 — Runtime Behaviors
|
||||
|
||||
#### Automatic Retry
|
||||
|
||||
Riverpod 3 automatically retries providers that throw, using exponential backoff. This improves resilience against transient network failures.
|
||||
|
||||
```dart
|
||||
// ✅ 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:**
|
||||
- Leave auto-retry enabled for network/IO operations (transient failures)
|
||||
- Disable for operations where retry is unsafe (non-idempotent writes) or pointless (validation errors)
|
||||
|
||||
#### Pause / Resume (Out-of-View Providers)
|
||||
|
||||
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` Wrapping
|
||||
|
||||
When a provider fails, reading it in Riverpod 3 throws a `ProviderException` wrapping the original error, not the raw exception.
|
||||
|
||||
```dart
|
||||
// ✅ 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
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// ✅ Assert on provider failure in tests
|
||||
expect(
|
||||
() => container.read(myProvider),
|
||||
throwsA(isA<ProviderException>()),
|
||||
);
|
||||
```
|
||||
|
||||
#### State Change Detection (`==`)
|
||||
|
||||
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 `==`)
|
||||
- Custom models **must** implement `==` / `hashCode` or use `freezed`
|
||||
- Override `updateShouldNotify` on a `Notifier` for custom comparison logic
|
||||
|
||||
---
|
||||
|
||||
### Async Patterns
|
||||
|
||||
1. **Always handle all three `AsyncValue` states: data, loading, error**
|
||||
```dart
|
||||
// ✅ Exhaustive
|
||||
asyncValue.when(
|
||||
data: (data) => DataWidget(data: data),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => ErrorText(err.toString()),
|
||||
);
|
||||
```
|
||||
|
||||
2. **Surfacing notifier errors to UI** — use `when(error:...)` for exhaustive handling; use `hasError` only for conditional checks alongside a separate data display:
|
||||
```dart
|
||||
// ✅ 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 [];
|
||||
```
|
||||
|
||||
3. **Use safe `AsyncValue` accessors to prevent runtime crashes**
|
||||
```dart
|
||||
// ✅ 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;
|
||||
```
|
||||
|
||||
4. **Use `AsyncValue.guard` inside notifier actions to wrap async calls**
|
||||
- It catches exceptions and wraps them in `AsyncError` automatically
|
||||
|
||||
5. **Use `StreamProvider` for real-time data** — never poll manually with `Timer`
|
||||
|
||||
6. **Always check `ref.mounted` after `await`** — see `Ref.mounted` section above
|
||||
|
||||
7. **Force a provider to re-fetch with `ref.invalidate`**
|
||||
```dart
|
||||
// ✅ 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())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error Handling in Flutter/Riverpod
|
||||
|
||||
Define a typed exception hierarchy using sealed classes (Dart 3+). Map infrastructure exceptions to domain exceptions inside notifier actions.
|
||||
|
||||
```dart
|
||||
// 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);
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// ✅ 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// ✅ 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:**
|
||||
- All custom exceptions extend `AppException` (sealed class)
|
||||
- Infrastructure exceptions (`DioException`, `SocketException`) are caught and re-thrown as domain exceptions
|
||||
- Never expose infrastructure types (Dio, HTTP status codes) to the UI layer
|
||||
- Use exhaustive `switch` on the sealed class in error display
|
||||
- See `error-handling-principles.md` for general error handling guidance
|
||||
|
||||
---
|
||||
|
||||
### Navigation with `go_router`
|
||||
|
||||
**`go_router` is the canonical navigation library.**
|
||||
|
||||
```dart
|
||||
// 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Dart Language Idioms
|
||||
|
||||
1. **Null safety — use `?.`, `??`, and `??=` idiomatically**
|
||||
```dart
|
||||
final city = user?.address?.city ?? 'Unknown';
|
||||
cache ??= await compute(); // assign only if null
|
||||
```
|
||||
|
||||
2. **Use `late` only for fields initialized before first use that cannot be `final`**
|
||||
- Prefer `final` fields initialized in the constructor
|
||||
- `late` without initialization is an unsafe nullable escape hatch
|
||||
|
||||
3. **Extension methods for adding behaviour to types you don't own**
|
||||
```dart
|
||||
extension TaskStatusLabel on TaskStatus {
|
||||
String get label => switch (this) {
|
||||
TaskStatus.pending => 'Pending',
|
||||
TaskStatus.done => 'Done',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
4. **Use `switch` expressions (Dart 3+) for exhaustive pattern matching**
|
||||
```dart
|
||||
final label = status switch {
|
||||
TaskStatus.pending => 'Pending',
|
||||
TaskStatus.done => 'Done',
|
||||
// Compiler error if a case is missing
|
||||
};
|
||||
```
|
||||
|
||||
5. **Avoid `dynamic` — it is the Dart equivalent of TypeScript's `any`**
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
> Test naming and pyramid proportions are defined in `testing-strategy.md`. This section covers Flutter/Riverpod 3 test patterns.
|
||||
|
||||
#### Unit Test Providers with `ProviderContainer.test`
|
||||
|
||||
`ProviderContainer.test` creates an isolated container that **auto-disposes after the test** — no manual `addTearDown` needed.
|
||||
|
||||
```dart
|
||||
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));
|
||||
});
|
||||
```
|
||||
|
||||
#### Override Only the `build` Method with `overrideWithBuild`
|
||||
|
||||
When you need to control initial state but keep the notifier's methods intact:
|
||||
|
||||
```dart
|
||||
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));
|
||||
});
|
||||
```
|
||||
|
||||
#### Widget Tests with `ProviderScope`
|
||||
|
||||
```dart
|
||||
testWidgets('shows task list', (tester) async {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
taskRepositoryProvider.overrideWith((_) => MockTaskRepository()),
|
||||
],
|
||||
child: const MaterialApp(home: TaskListView()),
|
||||
));
|
||||
expect(find.byType(TaskCard), findsWidgets);
|
||||
});
|
||||
```
|
||||
|
||||
#### Use `mockito` with `@GenerateNiceMocks` for Interface Mocks
|
||||
|
||||
Place the annotation on the library (top of the test file or on `main`) and add a `part` directive for the generated file:
|
||||
|
||||
```dart
|
||||
// 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()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns — NEVER DO THIS
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
| 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+):**
|
||||
```yaml
|
||||
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:**
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter analyze
|
||||
dart format .
|
||||
```
|
||||
|
||||
**During active development, use `watch` mode to avoid re-running manually:**
|
||||
```bash
|
||||
dart run build_runner watch --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Related Principles
|
||||
- Code Idioms and Conventions @code-idioms-and-conventions.md
|
||||
- Project Structure — Flutter Mobile @project-structure-flutter-mobile.md
|
||||
- Architectural Patterns — Testability-First Design @architectural-pattern.md
|
||||
- Testing Strategy @testing-strategy.md
|
||||
- Error Handling Principles @error-handling-principles.md
|
||||
- Dependency Management Principles @dependency-management-principles.md
|
||||
Reference in new issue
Block a user