Skip to content
Back to skills

Flutter Idioms

ASecurity

Flutter idioms: Riverpod 3 state management, Freezed immutable models, go_router navigation, const widget optimization, and repository patterns. Use when developing, refactoring, or reviewing Flutter mobile applications.

  • 157 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 5, 2026
developmenttypescriptgobashexpresstestingdebuggingrefactoringapisecurityperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 30, 2026

npx -y skills add irahardianto/antigravity-setup --skill flutter-idioms --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Flutter Idioms?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Flutter Idioms
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/irahardianto-flutter-idioms/badge)](https://www.skillsdirectory.com/skills/irahardianto-flutter-idioms)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: flutter-idioms
description: >-
  Flutter idioms: Riverpod 3 state management, Freezed immutable models, go_router navigation, const widget optimization, and repository patterns. Use when developing, refactoring, or reviewing Flutter mobile applications.
---

## 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 `references/project-structure.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 `references/project-structure.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 `@.agents/rules/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 @.agents/skills/flutter-idioms/references/project-structure.md
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Architectural Patterns — Testability-First Design @.agents/rules/architectural-pattern.md
- Testing Strategy @testing-strategy.md
- Error Handling Principles @error-handling-principles.md
- Dependency Management Principles @dependency-management-principles.md

Files in this skill

  • SKILL.md28.6 KB
  • references/project-structure.md5.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…