← All posts
·16 min read

Claude Code with Flutter: Widgets, State, Mobile Builds

Claude CodeFlutterMobileWorkflow
Claude Code with Flutter: Widgets, State, Mobile Builds

Why Flutter projects need a CLAUDE.md

Flutter is one of the harder frameworks to use Claude Code on without configuration. The framework moves quickly, the widget catalogue is enormous, the state management ecosystem has at least four serious contenders, and the same Dart code has to compile cleanly to two completely different mobile targets. Generated code that looks reasonable in the editor can still ship a broken iOS build or a janky Android frame.

Claude Code understands Flutter at a deep level. It knows the widget tree, the difference between StatelessWidget and StatefulWidget, the lifecycle methods, build context behaviour, the major state libraries (Riverpod, Bloc, Provider, GetX), the pub.dev ecosystem, and the platform channel pattern for native interop. What it does not know is which choices your project has already made: which state library you committed to, which packages you have approved, which Material or Cupertino style your design system uses, and which platform features you call out to native code for.

Without a CLAUDE.md that pins those decisions, Claude will mix Riverpod and Provider in the same project, pull in unpinned dependencies that break on the next pub get, generate widget trees that rebuild far too often, and write platform channel code that works on the simulator but crashes on a physical device. This guide covers the configuration and patterns that prevent those failures. If you have not set up Claude Code yet, the Claude Code setup guide covers installation and authentication first.

The CLAUDE.md at your project root is the first file Claude Code reads in every session. For a Flutter project, it needs to answer five questions: which Flutter and Dart versions are pinned, which state library is canonical, how widgets are composed and tested, how pub dependencies are added, and how the project builds for each target. Below is the template the rest of the patterns in this post slot into.

# Flutter project rules

## Stack
- Flutter: 3.27.x (stable channel, fvm-pinned)
- Dart: 3.6.x
- Min iOS: 14.0
- Min Android: API 23 (Android 6.0 Marshmallow)
- State management: Riverpod 2.6.x (flutter_riverpod + riverpod_generator)
- Routing: go_router 14.x
- Networking: dio 5.x with retry interceptor
- Local storage: drift 2.x for relational, shared_preferences for flags
- Linting: flutter_lints 5.x plus custom analysis_options.yaml

## Project structure
- lib/features/{feature}/ contains widgets/, providers/, models/, repository.dart (one folder per feature)
- lib/core/ holds shared widgets, theme, constants, extensions
- lib/router/ holds go_router configuration only
- lib/main.dart is the app entry: ProviderScope wrap, MaterialApp.router
- test/ mirrors lib/ structure exactly
- integration_test/ holds full-app tests run via flutter test integration_test

## Widget rules
- Prefer StatelessWidget. Convert to StatefulWidget only for animation controllers, focus nodes, or text controllers.
- Compose, do not nest deeply. If a build method exceeds 80 lines, extract sub-widgets.
- All public widgets accept a `Key? key` and pass it through to super.
- No business logic inside build(). Read state from a provider, render only.

## Running the project
- Dev (iOS sim): `flutter run -d ios`
- Dev (Android emulator): `flutter run -d emulator-5554`
- Tests: `flutter test`
- Integration: `flutter test integration_test`
- Analyze: `flutter analyze` (must be clean before any commit)

## Hard rules
- NEVER add a dependency without pinning the major.minor in pubspec.yaml (^1.2.x style only, no ranges open at the top).
- NEVER call setState inside build() or didUpdateWidget without a guard.
- ALL async work in widgets goes through a provider, never directly in initState.
- Platform-specific code lives in lib/core/platform/ behind a single abstract interface.
- flutter analyze must return zero issues before any code is considered complete.

Three rules in this CLAUDE.md prevent the most common Claude Code failures with Flutter.

The single state library rule is the most important. The Flutter ecosystem contains active code in Riverpod, Bloc, Provider, GetX, and a long tail of smaller libraries. Claude's training data covers all of them. Without an explicit pin, Claude will reach for whichever pattern best fits the immediate task, and over a few sessions you end up with three state libraries coexisting in one app. Pinning Riverpod (or whichever you choose) gives Claude a single mental model. The cost of mixing state libraries is not just style: each library has its own lifecycle, its own dependency graph, and its own debugger story. A bug that crosses the boundary between Provider and Bloc can take half a day to track down because the call stack passes through two completely different paradigms.

The platform interface rule stops the most common iOS-or-Android-only bug class. Without the rule, Claude will reach for Platform.isIOS checks scattered through feature code, and a feature that compiles fine on the simulator will break on the other platform because one branch was never tested. Forcing platform-specific code behind a single abstract interface means there is exactly one place to test on both targets. It also pays off when a third platform appears: the same app that ships to iOS and Android in 2026 may want to ship to macOS or web in 2027, and a clean platform abstraction is the difference between a week of work and a quarter of work for that addition.

The flutter analyze clean rule is a simple gate that catches an enormous amount of generated-code drift. Flutter's analyzer flags unused imports, missing const constructors, deprecated APIs, and a long list of style issues. When Claude knows zero analyzer issues is the bar, it self-corrects before claiming a task is done. Pair this with a custom analysis_options.yaml that turns warnings into errors for your specific concerns (avoid_print, always_use_package_imports, prefer_const_constructors) and the analyzer becomes a much stronger first line of defence than any human reviewer.

Widget conventions that hold up

Widget composition is where Claude Code does its best Flutter work, but only when the conventions are explicit. The framework allows enormous variation in how a UI is structured, and without rules Claude will produce inconsistent files that mix functional and class-based patterns, deep nesting, and inline business logic.

Add a widget patterns section to your CLAUDE.md:

## Widget patterns

### Stateless composition (default)
Build small, focused widgets. A feature screen is a composition of smaller widgets, not one large build method.

```dart
class ProductCard extends StatelessWidget {
  const ProductCard({super.key, required this.product});

  final Product product;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _ProductImage(url: product.imageUrl),
            const SizedBox(height: 8),
            _ProductTitle(text: product.name),
            _ProductPrice(amount: product.price),
          ],
        ),
      ),
    );
  }
}

Stateful only when required

StatefulWidget is correct for: AnimationController, TextEditingController, FocusNode, ScrollController, anything tied to a Ticker. For everything else, use a Riverpod provider.

class FadeInWrapper extends StatefulWidget {
  const FadeInWrapper({super.key, required this.child});
  final Widget child;

  @override
  State<FadeInWrapper> createState() => _FadeInWrapperState();
}

class _FadeInWrapperState extends State<FadeInWrapper>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    )..forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(opacity: _controller, child: widget.child);
  }
}

const everywhere it compiles

Every widget that takes only literal arguments must be const. Helps the analyzer cache and avoids unnecessary rebuilds.

No logic in build()

build() reads state and returns a widget tree. Calls to repositories, navigation, and side effects go through callbacks or notifier methods.


The const-everywhere rule is small but compounds. Flutter rebuilds widget subtrees on parent changes, and a non-const child rebuilds even when its inputs are identical. Across a real app this is the difference between a smooth list scroll and visible jank on a mid-tier Android device. Claude generates const constructors consistently when the rule is in CLAUDE.md. The same logic applies to ListView and GridView builders: passing a const item builder to a SliverList is much faster than passing a closure that captures non-const widgets, because the framework can skip rebuilds for items whose inputs have not changed.

The composition-over-nesting rule is what makes generated code reviewable. An eighty-line build method is impossible to read and harder to test. Forcing extraction at that threshold means Claude produces small, named widgets that show up cleanly in the widget inspector. Flutter's [official docs on composition](https://docs.flutter.dev/ui/widgets-intro) reinforce this approach for the same reasons. The eighty-line threshold is deliberate: it catches genuinely large widgets without forcing premature extraction of trivial trees. Set it lower and Claude extracts a sub-widget for every two-row Column, which clutters the codebase. Set it higher and large build methods slip through.

The no-logic-in-build rule deserves its own emphasis. A build method that calls a repository, navigates, or mutates state is hard to reason about because it conflates rendering with side effects. Worse, those side effects fire every time the parent rebuilds, which can be many times per second during animation. Push side effects out: callbacks for user-initiated actions, providers for state-driven work, and `addPostFrameCallback` only when you genuinely need to read layout information. Claude understands this distinction when it is in CLAUDE.md and stops generating build methods that quietly call `Navigator.push` mid-render.

## State management: Riverpod, with reasons

Pick one state library and commit to it. The right choice depends on team familiarity, but Riverpod is the recommended default for new Flutter work in 2026 for three reasons. It is compile-time safe, with code generation that catches missing providers before runtime. It does not require BuildContext to read providers, which means business logic can be tested without pumping a widget. And the dependency graph between providers is explicit, so refactors do not silently break unrelated screens.

Bloc is a strong second choice if your team is already deep in event-driven thinking and you want explicit Event-State pairs for every interaction. The trade-off is verbosity. A simple toggle that takes one line in Riverpod takes an event class, a state class, and a bloc method in Bloc. For complex flows with many discrete events (checkout, multi-step forms, orchestrated background work) the verbosity becomes a feature. For simple screens it is overhead.

Provider was the official answer before Riverpod. It still works, the code in production using it is fine, and you do not need to migrate a working app for the sake of it. For new work, Riverpod is what Provider's author wrote next, and the constraints it removes (BuildContext dependency, runtime-only errors) matter at scale.

Pin one and tell Claude. Add to CLAUDE.md:

```markdown
## State management (Riverpod)

### Provider definition (use riverpod_generator)
```dart
@riverpod
class CartNotifier extends _$CartNotifier {
  @override
  Cart build() => const Cart(items: []);

  void addItem(Product product) {
    state = state.copyWith(items: [...state.items, product]);
  }

  void removeItem(String productId) {
    state = state.copyWith(
      items: state.items.where((i) => i.id != productId).toList(),
    );
  }
}

Async provider for repository calls

@riverpod
Future<List<Product>> products(ProductsRef ref) async {
  final repo = ref.watch(productRepositoryProvider);
  return repo.fetchAll();
}

Reading from a widget

Use ConsumerWidget for read-only, ConsumerStatefulWidget when you also need StatefulWidget behavior.

class CartBadge extends ConsumerWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(cartNotifierProvider.select((c) => c.items.length));
    return Badge.count(count: count, child: const Icon(Icons.shopping_cart));
  }
}

Always use .select for slice subscriptions

ref.watch(provider) rebuilds on any state change. ref.watch(provider.select((s) => s.field)) rebuilds only when that field changes. Use select by default.


The `.select` rule is critical for performance. Without it, every widget that reads from a provider rebuilds whenever any field on that state changes. With it, widgets rebuild only when the slice they care about changes. Claude follows this pattern consistently when the rule is in CLAUDE.md, which significantly cuts the rebuild count on real screens. On a busy product screen with eight cards reading from a shared cart provider, the difference between watching the whole provider and watching `.select((c) => c.items.length)` is roughly an order of magnitude in rebuild cost during a single user session.

The riverpod_generator pattern is worth committing to. The generated providers are typed end to end, the IDE catches missing dependencies at compile time, and the generated code is idiomatic. Run `dart run build_runner watch` during development and let the generator handle the boilerplate. The annotation-driven approach also scales: dependency injection between providers is automatic, async providers wrap correctly in AsyncValue states, and the generator emits `.notifier` and `.future` accessors that match the canonical Riverpod patterns. Hand-written providers can drift from those conventions over time. Generated ones cannot.

One trap to avoid: do not use `ref.read` inside `build()`. `ref.read` is for one-shot operations inside callbacks (an onPressed, a future builder, a notifier method). Reading state with `ref.read` from inside `build` does not subscribe to changes, so the widget will not rebuild when the underlying state moves. Claude has been known to generate this pattern when the prose around it asks for "read the cart count without watching", which is almost never what you actually want. Add a hard rule that `ref.read` in `build()` is banned, and the failure mode disappears.

## pub.dev dependencies and version discipline

Flutter package management is where unmaintained projects rot fastest. A pubspec.yaml with twenty-eight unpinned dependencies will break unpredictably as packages publish breaking changes, transitive deps shift, and Flutter SDK upgrades. Claude is happy to pull in any package that solves the immediate problem, which makes dependency drift worse without rules.

Add to CLAUDE.md:

```markdown
## Dependencies (pubspec.yaml)

### Pinning rules
- Use ^1.2.x style for all direct deps. No git: deps without explicit ref pinning. No path: deps in production.
- Group dependencies in pubspec.yaml: state, networking, storage, UI, utils, dev.
- Run `flutter pub outdated` weekly. Bump majors deliberately, not as part of unrelated work.
- Before adding any new package, check: pub.dev score above 130, last publish under 12 months, null safety, supports current Flutter SDK.

### Approved packages (do not add others without explicit approval)
# pubspec.yaml structure
name: my_app
description: Flutter app.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.6.0
  flutter: ^3.27.0

dependencies:
  flutter:
    sdk: flutter

  # State
  flutter_riverpod: ^2.6.0
  riverpod_annotation: ^2.6.0

  # Routing
  go_router: ^14.6.0

  # Networking
  dio: ^5.7.0
  retry: ^3.1.2

  # Storage
  drift: ^2.21.0
  shared_preferences: ^2.3.0

  # UI
  flutter_svg: ^2.0.10
  cached_network_image: ^3.4.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  build_runner: ^2.4.13
  riverpod_generator: ^2.6.0
  drift_dev: ^2.21.0
  mocktail: ^1.0.4
### Forbidden patterns
- No dependency without a comment explaining why it is needed if its purpose is not obvious.
- No `dependency_overrides` in production pubspec.yaml. Use them only for short-term workarounds and remove them in the same week.
- No transitive deps pinned manually unless there is an open compatibility issue. Trust the resolver.

The approved-packages list is the single biggest leverage point. When Claude is allowed to reach for any pub package, you get inconsistent solutions to the same problem (three different HTTP clients, two icon libraries, four date formatters). When the list is explicit, Claude proposes additions when the existing tools genuinely cannot do the job, which is a much smaller and more reviewable set of changes.

Build, test, run, and platform channels

Mobile builds have more failure modes than backend code: signing, provisioning, target SDK levels, asset bundling, native dependencies, gradle versions, CocoaPods sync. Two areas in particular cause more breakage than the rest combined: platform channels (where Dart code calls into native iOS or Android) and the build pipeline itself (where a working flutter run does not guarantee a working flutter build ipa). The workflow rules in CLAUDE.md need to encode the actual sequence that produces a working build, plus the discipline that keeps platform-specific code from drifting.

Platform channels: native interop without breakage

Platform channels are the single most common source of works-on-one-platform-only bugs. Claude can write a method channel that compiles cleanly on both targets but only has a working implementation on the one you tested.

Add to CLAUDE.md:

## Platform channels (lib/core/platform/)

### Abstract interface (Dart side)
```dart
abstract interface class BiometricAuth {
  Future<bool> isAvailable();
  Future<bool> authenticate({required String reason});
}

class BiometricAuthImpl implements BiometricAuth {
  static const _channel = MethodChannel('app/biometric');

  @override
  Future<bool> isAvailable() async {
    try {
      return await _channel.invokeMethod<bool>('isAvailable') ?? false;
    } on PlatformException catch (e) {
      throw BiometricException(e.code, e.message);
    }
  }

  @override
  Future<bool> authenticate({required String reason}) async {
    try {
      return await _channel.invokeMethod<bool>(
        'authenticate',
        {'reason': reason},
      ) ?? false;
    } on PlatformException catch (e) {
      throw BiometricException(e.code, e.message);
    }
  }
}

Native handler must exist on BOTH platforms before merge

  • iOS: ios/Runner/AppDelegate.swift registers FlutterMethodChannel and handles every method name the Dart side calls.
  • Android: android/app/src/main/kotlin/.../MainActivity.kt does the same.
  • A method channel call with no native handler returns MissingPluginException at runtime. The Dart code compiles fine.

Test gate

  • Every platform channel method has an integration_test that runs on both flutter test integration_test -d ios and flutter test integration_test -d android before merge.

The MissingPluginException trap is the most common Flutter bug across teams that ship to both stores. The Dart code looks complete, the iOS implementation got written, the Android side was deferred, and the app crashes the first time an Android user hits that screen. The integration test gate makes the bug impossible to merge.

The single abstract interface pattern is what makes the rest of the app testable. Feature code depends on `BiometricAuth`, not on `BiometricAuthImpl`, which means tests can substitute a fake implementation without touching method channels at all.

### The build, test, run workflow

With platform channels handled, the rest of the build pipeline needs the same level of explicit configuration. Flutter's incremental build cache, gradle dependency resolution, and CocoaPods integration each have their own failure modes, and the recovery steps are not obvious from the error messages.

Add to CLAUDE.md:

```markdown
## Build / test / run workflow

### Daily development
# Format + analyze before any commit
dart format lib/ test/
flutter analyze

# Run all tests
flutter test
flutter test integration_test -d ios
flutter test integration_test -d android

# Run app on iOS simulator
flutter run -d ios

# Run app on Android emulator
flutter run -d emulator-5554

# Hot reload: r in the run terminal
# Hot restart: R in the run terminal
### Production builds
# iOS release build (creates .ipa for App Store)
flutter build ipa --release --flavor prod \
  --dart-define=API_BASE_URL=https://api.production.example

# Android release build (creates .aab for Play Store)
flutter build appbundle --release --flavor prod \
  --dart-define=API_BASE_URL=https://api.production.example

# Android APK for sideload testing
flutter build apk --release --flavor prod
### When the build breaks
1. `flutter clean` (clears build/, .dart_tool/, ios/Pods/)
2. `flutter pub get`
3. `cd ios && pod install --repo-update && cd ..` (iOS only)
4. Retry the build command.
5. If still broken, check Xcode (Product > Clean Build Folder) or Android Studio (File > Invalidate Caches).

### Test conventions
- Unit tests: lib/features/{x}/test/{x}_test.dart, no widget pumping
- Widget tests: test/{feature}/widgets/, pumpWidget + find + expect
- Integration tests: integration_test/{flow}_test.dart, real navigation, real platform channels
- Mock with mocktail, never mockito (mocktail does not require code generation)

The flutter clean recipe matters because Flutter's incremental build cache breaks in non-obvious ways after dependency updates, Xcode upgrades, or pod install rotations. Claude will keep retrying a failing build without clearing the cache unless told this is the first step. The five-step recovery is what an experienced Flutter engineer does automatically and it removes ninety percent of build-pipeline frustration.

The mocktail-not-mockito rule is small but real. Mockito requires generated code, which means another build_runner watcher, more boilerplate, and slower test suites. Mocktail uses runtime mocking with no codegen. Claude will reach for whichever it sees first in your repo, so pin the choice.

Hard rules: the conclusion

A working Flutter project with Claude Code on board needs three things in the CLAUDE.md to stay healthy. One state library, no exceptions. One platform channel pattern, abstract interface plus impl, native handlers on both targets before merge. One dependency policy, pinned majors and a curated package list.

The patterns in this guide produce a Flutter codebase where widgets compose cleanly, state lives in typed Riverpod providers, dependencies are pinned and audited, platform code is testable, and the build pipeline has a deterministic recovery path. That is what gives Claude Code enough scaffolding to ship features without breaking iOS or Android.

If you have not configured CLAUDE.md across your projects yet, the CLAUDE.md explainer covers the file format, precedence rules, and which sections matter most. The same principles apply across mobile work generally: the Claude Code with React Native guide covers the equivalent setup for the JavaScript-based mobile stack, and the Claude Code best practices guide covers the configuration habits that apply across all framework choices.

Two further pieces are worth mentioning. The Claude Code testing guide covers the testing layer in more depth, including how to structure widget and integration tests so Claude generates them consistently. And the Claude Code permissions guide covers how to gate destructive operations like flutter clean or build cache wipes behind explicit approval.

The principle is the same one that runs through every framework integration: Claude Code performs at the level of the context you give it. A Flutter project with no CLAUDE.md produces inconsistent state management, unpinned dependencies, platform-specific bugs, and broken release builds. A project with the configuration above produces Claude that follows your conventions from the first widget, builds composable trees, runs flutter analyze clean, and ships builds that work on both stores. Claudify includes a Flutter-specific CLAUDE.md template as part of the workflow kit, pre-configured for Riverpod, go_router, drift, and the platform channel patterns covered above.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir