You ask Claude to "add a profile screen that loads the user from the API" inside your Flutter codebase, and you get back something that builds on the simulator and is still wrong:
- A widget that calls
user!.namebecause the model doesn't want to fight the analyzer. - A 400-line
_HomePageStatewith three nestedFutureBuilders and asetStateafter everyawait. - An
await api.save(); Navigator.pop(context);with nomountedcheck — throwsThis widget has been unmountedin production whenever a user navigates away mid-call. - A
class FancyButton extends ElevatedButtonthat breaks the moment Material updates. - A
TextEditingControllerfield with nodispose(). - A raw
MethodChannel('com.app/foo').invokeMethod('doX', {'k': v})that returnsdynamicand silently breaks when iOS renames a key.
The model isn't lazy. It's been trained on a decade of Flutter examples, half from before sound null safety, half from "Hello World" tutorials that don't show error handling, dispose, or tests. The median is closer to "Flutter quickstart" than to a production app.
A CLAUDE.md at the root of your project drags it forward to where you actually live — Flutter 3.24+ with sound null safety, Riverpod or BLoC for state, typed platform channels, and a CI pipeline that runs flutter analyze and flutter test on every PR.
Here are 12 rules I drop into every Flutter project. Each one closes a class of bug AI assistants generate by default.
The 12 rules
1 — Sound null safety is non-negotiable. No !, no late without justification, no as T?. If a value can be null, the type says so and the caller handles it. Required widget params are non-nullable; optional rendering branches on null explicitly.
2 — const everywhere it's allowed. Every widget literal that can be const, must be. prefer_const_constructors, prefer_const_literals_to_create_immutables, and prefer_const_declarations are error in analysis_options.yaml. The framework canonicalizes const widgets — non-const ones force a fresh allocation on every rebuild.
3 — Composition over inheritance for widgets. Never extends ElevatedButton or extends Card. Build composite widgets as StatelessWidget (or StatefulWidget for local state) that compose framework widgets in build(). Wrap, don't extend.
4 — Immutable state with copyWith. Domain models are @immutable, all fields final, constructors const, updates return a new instance. For non-trivial models use freezed. Mutating a list in place breaks Riverpod/BLoC selectors because identity hasn't changed — the rebuild gets skipped and the UI silently lies.
5 — App state lives in Riverpod / BLoC, not setState. setState is reserved for ephemeral widget-local UI flags (focus, hover, an expansion toggle). Anything that crosses screens, persists, or feeds more than one widget belongs in a real state container. The 400-line _HomePageState is the AI's default for a reason — and it's the wrong default.
6 — Every await sits inside try/catch. Catch typed exceptions (ApiException, TimeoutException, SocketException) — never bare catch. Re-throw or report unknown exceptions to your crash reporter. A bare await that throws becomes an unhandled future error and the user sees a frozen screen.
7 — BuildContext across async gaps requires mounted. After every await that precedes a BuildContext use, check if (!mounted) return; inside State, or capture the dependency (Navigator, ScaffoldMessenger) before the await for non-State callers. The use_build_context_synchronously lint must be error, not warning.
8 — Dispose every controller, subscription, animation. Pair initState with dispose mechanically. TextEditingController, ScrollController, AnimationController, StreamSubscription, Timer — every one of them holds resources the framework will not free for you. Forgetting dispose() leaks across navigation; in a long session it visibly degrades performance.
9 — Platform channels through typed wrappers only. Wrap every MethodChannel in a typed Dart class with a sealed result type and PlatformException mapping. New channels use Pigeon-generated bindings. Raw invokeMethod with Map<String, dynamic> arguments is a contract you can't refactor — and the model will generate it every time.
10 — dynamic is a code smell. Allowed only at the JSON decode boundary (jsonDecode(...) as Map<String, dynamic>). Every layer above is fully typed via fromJson factories or freezed/json_serializable codegen. Enable avoid_dynamic_calls and strict-raw-types.
11 — Widget tests, not manual QA. Every new widget ships with a test/ file using flutter_test. At minimum: one happy-path interaction test and one disabled/error-state test. Mock at the Riverpod/BLoC layer, not at http.Client. WidgetTester is easier than the equivalent native UI test on iOS or Android — there's no excuse.
12 — print is debug-only; production uses a logger. No print() in committed code. Use the project logger (logger package or custom). Log levels: trace/debug for dev only, info/warn/error in release. Never log tokens, passwords, full email addresses, or full request bodies — they end up in flutter logs and Crashlytics.
Why a CLAUDE.md, not a system prompt
A system prompt lives in a chat window — it dies when the session ends and your team can't review it. A CLAUDE.md lives at the repo root: checked into git, code-reviewed, read by Claude Code on every turn. Pair the rules with analysis_options.yaml and a CI step that runs flutter analyze --fatal-infos and flutter test — the AI gets the rules, the analyzer enforces them, CI is the backstop.
The result is uncomfortable for the model and pleasant for you: it stops generating fragile mobile code by default, and your reviews stop being a checklist of the same six findings.
Get the full pack
The full 12-rule pack — with bad/good code examples, the exact analysis_options.yaml snippets, and CI guidance — is here:
→ oliviacraftlat.gumroad.com/l/skdgt
A free 3-rule sample is on GitHub: search for "CLAUDE.md for Flutter/Dart Free Sample" or grab the gist linked from my X profile @OliviaCraftLat.
Drop it at the root of your project, mirror the lints into analysis_options.yaml, and watch the model stop writing fragile mobile code.
— Olivia
Top comments (0)