Flutter Expert
Turns Claude into a senior Flutter engineer who ships idiomatic Dart 3 / Flutter 3.3x code with modern state management, correct navigation, and verified builds - not the 2021-era patterns that dominate training data.
When to Use This Skill
- Building screens, widgets, or features in an existing Flutter app
- Choosing and wiring state management (Riverpod code-gen providers, AsyncNotifier, Bloc/Cubit)
- Navigation with GoRouter: ShellRoute/StatefulShellRoute, auth redirects, deep links
- Diagnosing jank, excessive rebuilds, or slow lists
- Calling native platform code (MethodChannel, Pigeon) or C libraries (dart:ffi)
- Modeling data with freezed + json_serializable and Dart 3 sealed classes/records
- Writing widget tests, golden tests, and integration tests (integration_test, patrol)
- Release prep: flavors, Android signing, iOS provisioning basics
Core Workflow
- Analyze - Read
pubspec.yaml (Flutter/Dart SDK constraints, state-management and router packages, code-gen deps), analysis_options.yaml (lint set), and lib/ layout before writing anything. Match the project's existing state-management choice - do not introduce Riverpod into a Bloc app or vice versa. Note whether the project uses code generation (build_runner, freezed, riverpod_generator, go_router_builder).
- Implement - Write the change using Dart 3 idioms: sealed classes + exhaustive
switch expressions, records for lightweight tuples, pattern matching over manual casts, const constructors everywhere possible. Prefer code-gen providers (@riverpod) over manual ones, StatelessWidget/ConsumerWidget over StatefulWidget unless local ephemeral state is genuinely needed.
- Generate code - If the change touches
@riverpod, @freezed, @JsonSerializable, or typed routes, run dart run build_runner build --delete-conflicting-outputs; if generation fails, fix the annotated source and re-run until it completes with no errors.
- Verify analyze/format - Run
dart format . then flutter analyze; fix all reported issues and re-run until clean before proceeding. Treat infos/deprecations (e.g. withOpacity, WillPopScope) as real work items, not noise.
- Test - Write or update widget/unit tests for the change, then run
flutter test; fix all failures and re-run until every test passes. For flows spanning navigation or platform channels, add an integration_test (or patrol) case and run it on a device/emulator: flutter test integration_test; debug and re-run until it passes.
- Prove it works - Run the app (
flutter run -d <device> or the project's flavor variant, e.g. flutter run --flavor dev -t lib/main_dev.dart), exercise the changed flow, and check the console for exceptions and layout overflows. For performance work, confirm in DevTools (rebuild stats, frame chart) that the fix actually reduced rebuilds/frame times.
Reference Guide
Load detailed guidance only when the task needs it:
| Topic |
Reference |
Load When |
| State management: Riverpod 2/3 code-gen, AsyncNotifier, Bloc/Cubit, freezed models |
references/state-riverpod-bloc.md |
Adding/refactoring app state, async data loading, provider wiring, Bloc events/states, freezed/json_serializable models, or a "which state management" decision |
| Navigation: GoRouter routes, ShellRoute, redirect, deep links, typed routes |
references/navigation-gorouter.md |
Adding routes/tabs, auth-gated navigation, deep/app links, or nav-related bugs (back button, nested navigators) |
| Rebuild & render performance: const, keys, select, lists, DevTools |
references/performance-widgets.md |
Jank, dropped frames, "whole screen rebuilds", slow/scroll-stuttering lists, or any perf review |
| Platform integration: MethodChannel/EventChannel, Pigeon, dart:ffi; release flavors + signing |
references/platform-integration.md |
Calling Android/iOS native APIs, wrapping a C library, channel-related crashes, or setting up flavors/signing for release |
| Testing: widget tests, ProviderScope/Bloc overrides, goldens, integration_test, patrol |
references/testing.md |
Writing or fixing any Flutter test, flaky pumpAndSettle, mocking state/channels, device-level E2E |
Key Patterns
Riverpod code-gen AsyncNotifier (the default for async app state):
@riverpod
class Cart extends _$Cart {
@override
Future<List<CartItem>> build() => ref.watch(cartRepoProvider).fetch();
Future<void> add(Product p) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(cartRepoProvider).add(p);
return ref.read(cartRepoProvider).fetch();
});
}
}
// In build(): exhaustive handling, no .when soup needed in Dart 3
switch (ref.watch(cartProvider)) {
AsyncData(:final value) => CartList(items: value),
AsyncError(:final error) => ErrorView(error),
_ => const CircularProgressIndicator(),
}
Sealed state + exhaustive switch (Bloc or plain):
sealed class LoginState {}
final class LoginIdle extends LoginState {}
final class LoginLoading extends LoginState {}
final class LoginFailure extends LoginState { LoginFailure(this.message); final String message; }
final class LoginSuccess extends LoginState { LoginSuccess(this.user); final User user; }
// switch (state) { ... } - compiler errors if a case is missed; no `default`.
GoRouter auth redirect that re-evaluates on state change:
final router = GoRouter(
refreshListenable: authNotifier, // re-run redirect when auth changes
redirect: (context, state) {
final loggedIn = authNotifier.isLoggedIn;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn && !loggingIn) return '/login?from=${state.matchedLocation}';
if (loggedIn && loggingIn) return state.uri.queryParameters['from'] ?? '/';
return null; // no redirect
},
routes: [/* ... */],
);
Rebuild only what changed:
// Watch one field, not the whole object:
final name = ref.watch(userProvider.select((u) => u.name));
// Scoped MediaQuery lookups (3.10+): rebuilds only on size changes
final size = MediaQuery.sizeOf(context);
Common Mistakes
- Writing pre-code-gen Riverpod (
StateProvider, StateNotifierProvider, ChangeNotifierProvider) in projects using riverpod_generator. Use @riverpod classes (Notifier/AsyncNotifier); StateNotifier is legacy and removed from Riverpod 3's core.
ref.watch inside callbacks (onPressed, event handlers). watch is for build; use ref.read in callbacks and ref.listen for side effects like snackbars/navigation.
- Using
BuildContext across async gaps without a context.mounted guard - a real crash and an analyzer lint (use_build_context_synchronously). Check if (!context.mounted) return; after every await before touching context.
- Deprecated APIs from training data:
WillPopScope → PopScope(canPop:, onPopInvokedWithResult:); Color.withOpacity → withValues(alpha:); MediaQuery.of(context).size → MediaQuery.sizeOf(context); RaisedButton/FlatButton → ElevatedButton/TextButton; accentColor → colorScheme.secondary.
- Mixing
Navigator.push with GoRouter - the imperative push bypasses the router's URL/state and breaks deep links and web URLs. Use context.go/context.push (and know the difference: go replaces the stack per route hierarchy, push stacks).
ListView(children: [...]) for long/unbounded lists - builds everything eagerly. Use ListView.builder/.separated (with itemExtent or prototypeItem when heights are uniform), and give list items stable ValueKeys when reordering/removing.
- Null-safety by force: sprinkling
! and late to silence the compiler. Prefer promotion via patterns (if (user case User(:final email))), ??/?., and making fields non-nullable at construction; each ! and non-initialized late is a runtime crash waiting.
pumpAndSettle in tests with repeating animations (shimmer, CircularProgressIndicator) - times out. Pump fixed durations instead (await tester.pump(const Duration(milliseconds: 300))).
1---2name: flutter-expert3description: Use when working in a Flutter project - pubspec.yaml, *.dart files, lib/main.dart, analysis_options.yaml, android/ + ios/ folders, or mentions of Flutter, Dart, widgets, Riverpod, Bloc, or GoRouter. Builds features, state management, navigation, platform channels/FFI, and tests for Flutter 3.3x / Dart 3.x apps. Invoke for adding screens or widgets, wiring Riverpod or Bloc state, GoRouter navigation and deep links, fixing jank or excess rebuilds, calling native code, writing widget/integration tests, and release/flavor setup.4license: MIT5---67# Flutter Expert89Turns Claude into a senior Flutter engineer who ships idiomatic Dart 3 / Flutter 3.3x code with modern state management, correct navigation, and verified builds - not the 2021-era patterns that dominate training data.1011## When to Use This Skill1213- Building screens, widgets, or features in an existing Flutter app14- Choosing and wiring state management (Riverpod code-gen providers, AsyncNotifier, Bloc/Cubit)15- Navigation with GoRouter: ShellRoute/StatefulShellRoute, auth redirects, deep links16- Diagnosing jank, excessive rebuilds, or slow lists17- Calling native platform code (MethodChannel, Pigeon) or C libraries (dart:ffi)18- Modeling data with freezed + json_serializable and Dart 3 sealed classes/records19- Writing widget tests, golden tests, and integration tests (integration_test, patrol)20- Release prep: flavors, Android signing, iOS provisioning basics2122## Core Workflow23241. **Analyze** - Read `pubspec.yaml` (Flutter/Dart SDK constraints, state-management and router packages, code-gen deps), `analysis_options.yaml` (lint set), and `lib/` layout before writing anything. Match the project's existing state-management choice - do not introduce Riverpod into a Bloc app or vice versa. Note whether the project uses code generation (`build_runner`, `freezed`, `riverpod_generator`, `go_router_builder`).252. **Implement** - Write the change using Dart 3 idioms: sealed classes + exhaustive `switch` expressions, records for lightweight tuples, pattern matching over manual casts, `const` constructors everywhere possible. Prefer code-gen providers (`@riverpod`) over manual ones, `StatelessWidget`/`ConsumerWidget` over `StatefulWidget` unless local ephemeral state is genuinely needed.263. **Generate code** - If the change touches `@riverpod`, `@freezed`, `@JsonSerializable`, or typed routes, run `dart run build_runner build --delete-conflicting-outputs`; if generation fails, fix the annotated source and re-run until it completes with no errors.274. **Verify analyze/format** - Run `dart format .` then `flutter analyze`; fix all reported issues and re-run until clean before proceeding. Treat infos/deprecations (e.g. `withOpacity`, `WillPopScope`) as real work items, not noise.285. **Test** - Write or update widget/unit tests for the change, then run `flutter test`; fix all failures and re-run until every test passes. For flows spanning navigation or platform channels, add an `integration_test` (or patrol) case and run it on a device/emulator: `flutter test integration_test`; debug and re-run until it passes.296. **Prove it works** - Run the app (`flutter run -d <device>` or the project's flavor variant, e.g. `flutter run --flavor dev -t lib/main_dev.dart`), exercise the changed flow, and check the console for exceptions and layout overflows. For performance work, confirm in DevTools (rebuild stats, frame chart) that the fix actually reduced rebuilds/frame times.3031## Reference Guide3233Load detailed guidance only when the task needs it:3435| Topic | Reference | Load When |36|-------|-----------|-----------|37| State management: Riverpod 2/3 code-gen, AsyncNotifier, Bloc/Cubit, freezed models | `references/state-riverpod-bloc.md` | Adding/refactoring app state, async data loading, provider wiring, Bloc events/states, freezed/json_serializable models, or a "which state management" decision |38| Navigation: GoRouter routes, ShellRoute, redirect, deep links, typed routes | `references/navigation-gorouter.md` | Adding routes/tabs, auth-gated navigation, deep/app links, or nav-related bugs (back button, nested navigators) |39| Rebuild & render performance: const, keys, select, lists, DevTools | `references/performance-widgets.md` | Jank, dropped frames, "whole screen rebuilds", slow/scroll-stuttering lists, or any perf review |40| Platform integration: MethodChannel/EventChannel, Pigeon, dart:ffi; release flavors + signing | `references/platform-integration.md` | Calling Android/iOS native APIs, wrapping a C library, channel-related crashes, or setting up flavors/signing for release |41| Testing: widget tests, ProviderScope/Bloc overrides, goldens, integration_test, patrol | `references/testing.md` | Writing or fixing any Flutter test, flaky `pumpAndSettle`, mocking state/channels, device-level E2E |4243## Key Patterns4445**Riverpod code-gen AsyncNotifier (the default for async app state):**4647```dart48@riverpod49class Cart extends _$Cart {50 @override51 Future<List<CartItem>> build() => ref.watch(cartRepoProvider).fetch();5253 Future<void> add(Product p) async {54 state = const AsyncLoading();55 state = await AsyncValue.guard(() async {56 await ref.read(cartRepoProvider).add(p);57 return ref.read(cartRepoProvider).fetch();58 });59 }60}6162// In build(): exhaustive handling, no .when soup needed in Dart 363switch (ref.watch(cartProvider)) {64 AsyncData(:final value) => CartList(items: value),65 AsyncError(:final error) => ErrorView(error),66 _ => const CircularProgressIndicator(),67}68```6970**Sealed state + exhaustive switch (Bloc or plain):**7172```dart73sealed class LoginState {}74final class LoginIdle extends LoginState {}75final class LoginLoading extends LoginState {}76final class LoginFailure extends LoginState { LoginFailure(this.message); final String message; }77final class LoginSuccess extends LoginState { LoginSuccess(this.user); final User user; }78// switch (state) { ... } - compiler errors if a case is missed; no `default`.79```8081**GoRouter auth redirect that re-evaluates on state change:**8283```dart84final router = GoRouter(85 refreshListenable: authNotifier, // re-run redirect when auth changes86 redirect: (context, state) {87 final loggedIn = authNotifier.isLoggedIn;88 final loggingIn = state.matchedLocation == '/login';89 if (!loggedIn && !loggingIn) return '/login?from=${state.matchedLocation}';90 if (loggedIn && loggingIn) return state.uri.queryParameters['from'] ?? '/';91 return null; // no redirect92 },93 routes: [/* ... */],94);95```9697**Rebuild only what changed:**9899```dart100// Watch one field, not the whole object:101final name = ref.watch(userProvider.select((u) => u.name));102// Scoped MediaQuery lookups (3.10+): rebuilds only on size changes103final size = MediaQuery.sizeOf(context);104```105106## Common Mistakes107108- **Writing pre-code-gen Riverpod** (`StateProvider`, `StateNotifierProvider`, `ChangeNotifierProvider`) in projects using `riverpod_generator`. Use `@riverpod` classes (`Notifier`/`AsyncNotifier`); `StateNotifier` is legacy and removed from Riverpod 3's core.109- **`ref.watch` inside callbacks** (`onPressed`, event handlers). `watch` is for `build`; use `ref.read` in callbacks and `ref.listen` for side effects like snackbars/navigation.110- **Using `BuildContext` across async gaps** without a `context.mounted` guard - a real crash and an analyzer lint (`use_build_context_synchronously`). Check `if (!context.mounted) return;` after every `await` before touching context.111- **Deprecated APIs from training data:** `WillPopScope` → `PopScope(canPop:, onPopInvokedWithResult:)`; `Color.withOpacity` → `withValues(alpha:)`; `MediaQuery.of(context).size` → `MediaQuery.sizeOf(context)`; `RaisedButton`/`FlatButton` → `ElevatedButton`/`TextButton`; `accentColor` → `colorScheme.secondary`.112- **Mixing `Navigator.push` with GoRouter** - the imperative push bypasses the router's URL/state and breaks deep links and web URLs. Use `context.go`/`context.push` (and know the difference: `go` replaces the stack per route hierarchy, `push` stacks).113- **`ListView(children: [...])` for long/unbounded lists** - builds everything eagerly. Use `ListView.builder`/`.separated` (with `itemExtent` or `prototypeItem` when heights are uniform), and give list items stable `ValueKey`s when reordering/removing.114- **Null-safety by force:** sprinkling `!` and `late` to silence the compiler. Prefer promotion via patterns (`if (user case User(:final email))`), `??`/`?.`, and making fields non-nullable at construction; each `!` and non-initialized `late` is a runtime crash waiting.115- **`pumpAndSettle` in tests with repeating animations** (shimmer, `CircularProgressIndicator`) - times out. Pump fixed durations instead (`await tester.pump(const Duration(milliseconds: 300))`).