Cursor Rules for Flutter: The Complete Guide to AI-Assisted Flutter & Dart Development
Flutter is the framework where "it runs" hides the longest lie. The app launches, the hot reload ticks, the screen paints a ListView of a thousand items, and nothing in flutter run tells you that every scroll repaints every card because the widgets aren't const, that the setState in the parent just rebuilt the entire tree for a single counter tap, that the Future.then chain has three untracked errors silently absorbed by the framework, or that the Provider.of<User>(context) call in build is subscribing the whole page to a change that only one button cares about. The app ships. A jank frame shows up in the Play Store vitals two sprints later.
Then you add an AI assistant.
Cursor and Claude Code were trained on a planet's worth of Flutter — most of it Flutter 1.x and early 2.x, pre-null-safety, pre-Riverpod-2, pre-flutter_lints. Ask for "a list screen that loads users and lets you filter them," and you get a StatefulWidget with fifteen nullable fields, a FutureBuilder that rebuilds on every rebuild, a ListView without itemExtent, handlers that call setState inside async gaps with no mounted check, print statements instead of logging, and a MaterialApp that hardcodes strings in English. It runs. It's not the Flutter you would ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Flutter looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Flutter Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything bigger than a single-app repo). For Flutter I recommend modular rules so a melos monorepo's platform-channel conventions don't bleed into a pure-Dart package's public-API constraints:
.cursor/rules/
flutter-core.mdc # null safety, const, widget composition
flutter-state.mdc # Riverpod / BLoC, no setState in big trees
flutter-async.mdc # Futures, Streams, mounted guards
flutter-perf.mdc # keys, itemExtent, RepaintBoundary
flutter-testing.mdc # widget tests, pumpAndSettle, golden
Frontmatter controls activation: globs: ["**/*.dart"] with alwaysApply: false. Now the rules.
Rule 1: Strict Null Safety and Strong Dart — No Dynamic, No ! Sprinkle
The most common AI failure in Dart is the !-sprinkle — every nullable value forced with ! because "we know it's there." Cursor generates user!.email!.trim() and the first time the API returns a user without an email the app crashes with Null check operator used on a null value in release mode, with no stack trace a non-Flutter engineer can read. Same story with dynamic: it defaults to dynamic for JSON, sprinkles as String casts, and pushes runtime-type errors into production.
The rule:
analysis_options.yaml enables:
- package:flutter_lints/flutter.yaml (or package:very_good_analysis/analysis_options.yaml)
- strict-casts: true
- strict-inference: true
- strict-raw-types: true
- implicit-dynamic: false
No `dynamic` anywhere. Unknown external data is typed as Object? / Map<String, Object?>
and narrowed with pattern-matching or a typed parser.
No bang operator (`!`) except when a prior check mathematically proves non-null
(immediately after `if (x == null) return;` or inside `?.` callbacks guaranteed by the framework).
Prefer `?.`, `??`, `??=`, and `if case` pattern narrowing.
Model classes are immutable: `final` fields, `const` constructors,
generated `copyWith`/`fromJson`/`toJson` via freezed or json_serializable.
No mutable model classes.
Enums use enhanced-enum members with typed fields, not int constants.
Sealed classes (Dart 3 `sealed class`) for variant results.
Before — nullable-by-accident model, ! everywhere, dynamic JSON:
class User {
String? name;
String? email;
int? age;
}
User parse(dynamic json) {
final u = User();
u.name = json['name'];
u.email = json['email'];
u.age = json['age'];
return u;
}
void greet(User u) {
print('Hi ${u.name!.toUpperCase()}, age ${u.age! + 1}'); // crashes on null
}
After — immutable freezed model, typed parser, narrowed access:
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
int? age,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
void greet(User u) {
final greeting = u.age == null
? 'Hi ${u.name.toUpperCase()}'
: 'Hi ${u.name.toUpperCase()}, age ${u.age! + 1}';
// ^ safe — narrowed by the `==` check above
debugPrint(greeting);
}
u.name is non-nullable by construction. age is nullable and the compiler forces you to handle both branches. dynamic is gone.
Rule 2: const Everything — The Single Biggest Perf Win You Can Automate
Every widget not marked const is rebuilt on every parent rebuild. A Text('Hello') in a nested Column rebuilds twenty times during a scroll because it's not const. The Flutter engine short-circuits identical const instances, so adding one keyword in front of a widget literal is often the difference between a smooth 120Hz scroll and dropped frames. Cursor rarely adds const — its training data predates the lint that nudges for it.
The rule:
Every widget literal that can be `const` must be `const`.
Every constructor with only final fields takes a `const` constructor.
analysis_options.yaml turns on:
prefer_const_constructors: error
prefer_const_literals_to_create_immutables: error
prefer_const_declarations: error
prefer_const_constructors_in_immutables: error
unnecessary_const: info (harmless but noisy — info-level)
Never write `new Widget(...)`. Never write `Widget(...)` when `const Widget(...)` is possible.
Propagate const across composite widgets — a widget that accepts only const children
should itself be constructible as const.
EdgeInsets, TextStyle, Duration, Color — always const literals, never computed
inline when the values are static.
Before — no const, whole list card tree rebuilt on every frame:
class UserCard extends StatelessWidget {
final User user;
UserCard({required this.user});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.person, size: 24),
SizedBox(width: 8),
Text(user.name, style: TextStyle(fontSize: 16)),
],
),
);
}
}
After — const propagated, rebuilds stop at the only non-const node:
class UserCard extends StatelessWidget {
const UserCard({super.key, required this.user});
final User user;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.person, size: 24),
const SizedBox(width: 8),
Text(user.name, style: const TextStyle(fontSize: 16)),
],
),
);
}
}
Padding, Icon, SizedBox, and TextStyle are now identical instances across rebuilds. Only the Text with the user name recomputes.
Rule 3: StatelessWidget by Default, Composition Over Inheritance
Cursor's Flutter instinct is StatefulWidget. "Make it a button that toggles" — StatefulWidget. "A card that expands" — StatefulWidget. Most of these should be stateless widgets composed with an AnimatedContainer, ExpansionTile, or a leaf StatefulWidget buried inside a stateless parent. A StatefulWidget is a commitment to a State object and a lifecycle; every stateful node is a future bug with initState, didUpdateWidget, and dispose to keep synchronized.
The rule:
Default widget is StatelessWidget. Reach for StatefulWidget only when:
- You own a controller (AnimationController, TextEditingController,
ScrollController) that needs dispose()
- You manage locally-scoped state no parent needs to read (small UI toggles)
- You need lifecycle (didChangeDependencies / didUpdateWidget)
Shared state lives in a state-management solution (Riverpod, BLoC, Redux),
never in a parent StatefulWidget passed down via constructor.
Prefer composition: build a complex widget by combining Row, Column, Stack,
ListView, CustomScrollView with small focused children — not by subclassing
an existing widget and overriding build.
Every widget file exports one public widget. Private helpers are `_Foo`
or top-level private functions returning `Widget`, never hoisted to classes
across files.
`build` must be pure. No side effects (no analytics, no controller.addListener).
Move side effects to initState / didChangeDependencies / post-frame callbacks.
Before — StatefulWidget for a pass-through prop, side effects in build:
class ProductTile extends StatefulWidget {
final Product product;
ProductTile({required this.product});
@override _ProductTileState createState() => _ProductTileState();
}
class _ProductTileState extends State<ProductTile> {
@override
Widget build(BuildContext context) {
analytics.log('tile_viewed', widget.product.id); // SIDE EFFECT IN BUILD
return ListTile(
title: Text(widget.product.name),
subtitle: Text('\$${widget.product.price}'),
);
}
}
Every rebuild logs a tile_viewed analytics event. Rotating the device fires a hundred of them.
After — stateless, analytics moved to a lifecycle-aware place:
class ProductTile extends StatelessWidget {
const ProductTile({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
);
}
}
// Analytics done once per visible tile by the parent list via VisibilityDetector
// or a dedicated scroll controller — never inside build().
build is pure. Parent owns when to log.
Rule 4: Riverpod for State — No setState Across Widget Boundaries
setState is fine for a toggle inside a single widget. It is a bug the moment two widgets need to read the same value. Cursor happily generates a parent StatefulWidget holding List<Item> items passed down through three levels, with a callback back up to call setState((){ items.add(newItem); }). That's the setup for a prop-drill, a re-render of every sibling, and a full-screen flash on every keystroke. Riverpod (preferred for new apps), BLoC, or Redux scope rebuilds to only the widgets that read the specific piece of state that changed.
The rule:
State that outlives a single widget lives in Riverpod providers (or BLoC /
Redux for existing codebases). Never lift state into a parent StatefulWidget
for cross-widget sharing.
Riverpod conventions:
- `@riverpod` annotation with code generation (riverpod_generator). No manual
StateProvider / StateNotifierProvider for new code.
- AsyncNotifier / AsyncValue for anything async. Never resolve in `build`
with a FutureBuilder and then set a field.
- `ref.watch` in build for data reads; `ref.read` only in event handlers
(onPressed, onSubmitted). Never `ref.watch` in onPressed.
- `ref.listen` for side effects (snackbars, navigation) — never in `build`.
- Providers are `Notifier`-based; expose intent-named methods
(`cart.addItem(id)`), not generic setters.
`setState` only for:
- Animation controllers local to the widget
- Small UI toggles (expanded/collapsed) no other widget reads
- Form field focus state owned by a single form
Every screen widget is a ConsumerWidget (or ConsumerStatefulWidget).
Never pass a Riverpod-backed value as a constructor argument — read it
from the provider at the point of use.
Before — setState at the top, prop-drill through three levels:
class CartPage extends StatefulWidget {
@override _CartPageState createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
List<CartItem> items = [];
void addItem(CartItem it) => setState(() => items.add(it));
@override
Widget build(BuildContext context) {
return CartBody(items: items, onAdd: addItem);
}
}
class CartBody extends StatelessWidget {
final List<CartItem> items;
final void Function(CartItem) onAdd;
const CartBody({super.key, required this.items, required this.onAdd});
@override
Widget build(BuildContext context) {
return Column(children: [
CartList(items: items),
AddItemButton(onAdd: onAdd),
]);
}
}
Adding an item rebuilds CartPage, CartBody, CartList, and the button — even the button text didn't change.
After — Riverpod notifier, rebuilds scoped to the consumer:
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => const [];
void addItem(CartItem it) => state = [...state, it];
}
class CartPage extends ConsumerWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Column(children: [CartList(), AddItemButton()]);
}
}
class CartList extends ConsumerWidget {
const CartList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(cartProvider);
return ListView(children: [for (final it in items) CartItemTile(item: it)]);
}
}
class AddItemButton extends ConsumerWidget {
const AddItemButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () => ref.read(cartProvider.notifier).addItem(CartItem.sample()),
child: const Text('Add item'),
);
}
}
AddItemButton does not rebuild when the cart changes. CartList rebuilds only because it watches the provider. CartPage is const.
Rule 5: Async Discipline — mounted Guards, Structured Cancellation, Typed Errors
The pattern Cursor loves most and is most wrong about: onPressed: () async { final x = await api.load(); setState(() => data = x); }. The user taps, then navigates away. The await completes. setState fires on a disposed State object. Debug throws, release leaks memory or crashes. Same story with Future.then chains that swallow errors, and with streams that are never cancelled.
The rule:
After every `await` in a State method, check `if (!mounted) return;`
before calling setState or using `context`. No exceptions.
Async work that outlives a widget goes in a Riverpod Notifier or
a dedicated service — never in `State`.
Never use `Future.then(...).catchError(...)` — use `try/await/catch`
with typed exceptions. Chain operators lose stack traces.
Streams returned from services are subscribed via `ref.listen` or
StreamBuilder. Manual `stream.listen` requires a StreamSubscription
stored and cancelled in dispose().
Typed errors: define `sealed class ApiError` with variants
(NetworkError, TimeoutError, ServerError, ValidationError(Map fields)).
Callers pattern-match, not string-compare.
Every network call takes a timeout (Duration, no defaults). Long-running
UI work uses `Future.delayed` only for animation choreography, never
to "wait for data".
Before — missing mounted guard, .then chain, untyped error:
class _ProfilePageState extends State<ProfilePage> {
User? user;
void load() {
api.getUser(widget.id).then((u) {
setState(() => user = u); // crashes if widget disposed
}).catchError((e) {
print(e); // swallowed — no UI feedback, no typed error
});
}
}
After — Riverpod AsyncNotifier, no State at all:
@riverpod
class Profile extends _$Profile {
@override
Future<User> build(String id) async {
try {
return await ref.read(apiProvider).getUser(id).timeout(const Duration(seconds: 10));
} on TimeoutException {
throw const ApiError.timeout();
} on SocketException catch (e) {
throw ApiError.network(e.message);
}
}
}
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider(id));
return switch (profile) {
AsyncData(:final value) => _Profile(user: value),
AsyncError(:final error) => _ErrorView(error: error as ApiError),
_ => const Center(child: CircularProgressIndicator()),
};
}
}
No mounted dance. Cancellation happens when the provider is disposed. Errors are typed with sealed class ApiError. switch exhaustively matches every state.
Rule 6: Lists That Scale — ListView.builder, Keys, itemExtent
Cursor's default list is ListView(children: items.map((x) => Tile(x)).toList()). That's fine for twelve items. For three hundred it builds every widget up-front and Flutter reports ListView as the heaviest widget in the tree. For dynamic lists with reordering, a missing Key means the state attaches to the wrong tile when the order changes — the classic "I tick checkbox on item A and the check moves to item B" bug.
The rule:
Long or unknown-length lists use `ListView.builder` / `ListView.separated`
with `itemCount` and a typed `itemBuilder`. Never `ListView(children: ...)`
with `.map().toList()` for lists longer than ~20 items.
Every item in a builder gets a stable `ValueKey<T>(item.id)` (or a typed
`Key` subclass). List item widgets that carry state (TextField, Checkbox,
Dismissible) REQUIRE a unique key. No ObjectKey unless absolutely necessary.
Provide `itemExtent` or `prototypeItem` when items have a known uniform
height — this lets Flutter skip layout for off-screen items and
dramatically improves scroll performance.
Use `SliverList` + `CustomScrollView` when you need slivers interleaved
with the list (sticky headers, collapsing toolbars).
Wrap heavy item widgets (images, charts) in a RepaintBoundary and
keep images through `cacheWidth` / `cacheHeight` sized to the actual
render size — not the source.
Infinite scroll uses a pagination controller (Riverpod AsyncNotifier
returning a PagedData<T>) — never fetches on `ListView.onEndReached`
callbacks with ad-hoc booleans.
Before — eager list build, no keys, no extent, full-res images:
ListView(
children: products.map((p) {
return ProductTile(
product: p,
imageUrl: p.imageUrl, // 2000x2000 JPEG rendered at 80x80
);
}).toList(),
);
After — lazy builder, keys, extent, bounded image cache:
ListView.builder(
itemCount: products.length,
itemExtent: 88, // known uniform height
itemBuilder: (context, i) {
final p = products[i];
return RepaintBoundary(
child: ProductTile(
key: ValueKey<String>(p.id),
product: p,
imageUrl: p.imageUrl,
cacheWidth: 160, // 2x physical pixels for an 80px tile
),
);
},
);
Off-screen tiles are never laid out. Reordering the list doesn't move checkbox state across tiles. The image decoder caches at 160px, not 2000px.
Rule 7: Platform-Aware Widgets, Theming, and Internationalization
Cursor's default Flutter app hardcodes strings in English, hardcodes colors as Colors.blue, and uses Material widgets on every platform — giving you Android-styled buttons on iOS and unreadable text in dark mode. Production apps theme explicitly, i18n every user-facing string, and use .adaptive constructors where the Cupertino/Material distinction is meaningful.
The rule:
No hardcoded user-facing strings. Every string goes through
AppLocalizations (gen-l10n / flutter_localizations) and has an entry
in all supported locales. Placeholders are typed
(AppLocalizations.of(context)!.greeting(userName)).
No hardcoded colors, text styles, or paddings in widget files.
Read from `Theme.of(context).colorScheme.primary`,
`Theme.of(context).textTheme.bodyMedium`. Raw Colors.* only in
theme definition files.
Support dark mode: define `ThemeData.light()` and `ThemeData.dark()`.
Never call MediaQuery.of(context).platformBrightness in a widget —
let MaterialApp's theme / darkTheme switch it.
Use adaptive widgets where behavior differs on platform:
- Switch.adaptive, CupertinoSwitch on iOS
- Scaffold + CupertinoPageScaffold with a platform shim
- showAdaptiveDialog over showDialog where relevant
Safe-area insets via `SafeArea` at page scaffolds. Never pad the
top with a hardcoded 44.
Respect text scale factor — never constrain text with a fixed pixel
height that clips at 1.5x scaling. Use `Flexible` / `Expanded` or
`minimumSize` on buttons.
Before — hardcoded strings, raw colors, platform-agnostic switch:
Scaffold(
appBar: AppBar(title: const Text('Settings'), backgroundColor: Colors.blue),
body: Column(
children: [
Text('Enable notifications', style: TextStyle(fontSize: 16, color: Colors.black)),
Switch(value: enabled, onChanged: onToggle),
],
),
);
After — themed, localized, adaptive:
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)),
body: SafeArea(
child: Column(
children: [
Text(l10n.enableNotifications, style: theme.textTheme.bodyLarge),
Switch.adaptive(value: enabled, onChanged: onToggle),
],
),
),
);
Flip dark mode — text contrast is right. Translate to Japanese — the label fits. Run on iOS — the switch is Cupertino-styled.
Rule 8: Widget Tests With Finder-Driven Interaction, Not State Inspection
Cursor's default Flutter test is expect(find.byType(MyPage), findsOneWidget); — implementation tests that pass on an empty page. Modern Flutter tests use WidgetTester to interact the way a user would: tap buttons, enter text, pump frames, assert on user-visible output. Backed by pumpAndSettle, Finder composition, and golden tests for visual regressions on critical surfaces.
The rule:
Widget tests use WidgetTester with pumpWidget / tap / enterText / drag,
then assert on Finder queries (find.text, find.bySemanticsLabel,
find.byKey) — never on private state objects or `tester.state(...)`.
Wrap the widget under test in a ProviderScope (Riverpod) with overrides
for external services. Never let a widget test hit a real API.
Await settled state with `await tester.pumpAndSettle()` after any action
that triggers animation or async. Never use hard sleeps or arbitrary pumps.
Assert on user-visible text via `find.text(...)` or semantics via
`find.bySemanticsLabel(...)`. No `find.byType(Text)` and then inspecting
widget properties.
Golden tests (`matchesGoldenFile`) for visually-critical surfaces
(empty states, error states, typography). Run on a fixed test platform
(`flutter test --platform chrome` or the default engine) with pixel
ratios pinned.
Integration tests (`integration_test` package) for end-to-end flows —
launch, login, critical purchase. Stubbed at the HTTP boundary with
http_mock_adapter or mocktail, not at the repository layer.
Never use `Duration.zero` awaits or `WidgetsBinding.instance.addPostFrameCallback`
in tests to hack past async — fix the production code to be awaitable.
Before — find.byType, state inspection, synchronous assertion on async:
testWidgets('loads user', (tester) async {
await tester.pumpWidget(ProfilePage(id: '1'));
tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
final state = tester.state<_ProfilePageState>(find.byType(ProfilePage));
expect(state.user, isNotNull); // racy
});
No HTTP stubbing. Breaks if _ProfilePageState is renamed.
After — provider override, settled pump, user-facing assertion:
testWidgets('shows the user name after fetch resolves', (tester) async {
final fakeApi = FakeApi()..stub('/users/42', User(id: '42', name: 'Ada Lovelace'));
await tester.pumpWidget(
ProviderScope(
overrides: [apiProvider.overrideWithValue(fakeApi)],
child: const MaterialApp(home: ProfilePage(id: '42')),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Ada Lovelace'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
await tester.enterText(find.bySemanticsLabel('Name'), 'Grace Hopper');
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
await tester.pumpAndSettle();
expect(fakeApi.lastPutBody, equals({'name': 'Grace Hopper'}));
});
Reads like user behavior. Survives private-class renames. Catches the real contract — the PUT body.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Flutter — Production Patterns
## Strict Dart & Null Safety
- analysis_options: flutter_lints or very_good_analysis; strict-casts,
strict-inference, strict-raw-types, implicit-dynamic: false.
- No `dynamic`. External data typed Object? / Map<String, Object?> and
narrowed with patterns or typed parsers.
- No bang operator except immediately after a proof-of-non-null check.
- Immutable models: freezed / json_serializable with final fields and
const constructors.
- Enhanced enums for typed constants; sealed classes for variant results.
## const Propagation
- Every widget literal that can be const is const. prefer_const_constructors
at error level.
- const constructors on every immutable widget. Propagate const up the tree.
- EdgeInsets, TextStyle, Duration, Color — const literals only.
## Widget Composition
- Default to StatelessWidget. StatefulWidget only for controllers, local UI
toggles, or lifecycle.
- `build` is pure. No analytics, listener registration, or setState inside build.
- Compose via Row/Column/Stack/ListView — never subclass existing widgets.
- One public widget per file; private helpers prefixed with `_`.
## State Management
- Cross-widget state in Riverpod (@riverpod codegen) or BLoC. Never lift
state into a parent StatefulWidget for sharing.
- ConsumerWidget / ConsumerStatefulWidget for screens.
- `ref.watch` in build, `ref.read` in handlers, `ref.listen` for side effects.
- AsyncNotifier + AsyncValue for async state; no FutureBuilder setting a field.
- Notifier methods are intent-named (`cart.addItem`) — never generic setters.
## Async Discipline
- After every await in a State method: `if (!mounted) return;` before setState
or context use.
- No Future.then / catchError chains — use try/await/catch with typed exceptions.
- Streams cancelled in dispose() if subscribed manually.
- Every network call has an explicit timeout.
- Sealed-class errors (NetworkError, TimeoutError, ServerError, ValidationError).
## Lists & Performance
- Long/unknown-length lists use ListView.builder / ListView.separated.
- Stable ValueKey<T>(item.id) on every builder item with state.
- itemExtent or prototypeItem when heights are uniform.
- SliverList + CustomScrollView for mixed-sliver layouts.
- RepaintBoundary on heavy leaves; cacheWidth/cacheHeight sized to render size.
- Pagination via Riverpod AsyncNotifier<PagedData<T>> — never ad-hoc booleans.
## Platform, Theme, i18n
- No hardcoded user-facing strings; AppLocalizations everywhere.
- No hardcoded colors/styles in widget files; Theme.of(context).*.
- Light + dark ThemeData; let MaterialApp switch via theme/darkTheme.
- .adaptive constructors where platform behavior differs.
- SafeArea at scaffold; respect text scale factor.
## Testing
- Widget tests via WidgetTester; assert on find.text / find.bySemanticsLabel /
find.byKey. No state-object inspection.
- ProviderScope with overrides for Riverpod; stub HTTP at the boundary.
- await pumpAndSettle() after any action that triggers async or animation.
- Golden tests on critical surfaces; pinned platform and pixel ratio.
- Integration tests for end-to-end flows with integration_test package.
End-to-End Example: A Search Field Filtering a List of Products
Without rules: StatefulWidget holding the query and results, FutureBuilder on every keystroke, no debounce, eager ListView, no keys, no mounted guard, hardcoded English strings.
class SearchPage extends StatefulWidget {
@override _SearchPageState createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
String q = '';
List<Product> results = [];
void onChanged(String v) {
setState(() => q = v);
api.search(v).then((r) {
setState(() => results = r);
});
}
@override
Widget build(BuildContext context) {
return Column(children: [
TextField(onChanged: onChanged, decoration: InputDecoration(hintText: 'Search')),
Expanded(
child: ListView(children: results.map((p) => ProductTile(product: p)).toList()),
),
]);
}
}
With rules: Riverpod debounced AsyncNotifier, ConsumerWidget, builder list with keys, l10n, const everywhere it fits.
@riverpod
class ProductSearch extends _$ProductSearch {
@override
Future<List<Product>> build() async => const [];
Timer? _debounce;
Future<void> search(String query) async {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 200), () async {
state = const AsyncLoading();
state = await AsyncValue.guard(() =>
ref.read(apiProvider).search(query).timeout(const Duration(seconds: 10)));
});
}
}
class SearchPage extends ConsumerWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final results = ref.watch(productSearchProvider);
return Column(
children: [
TextField(
onChanged: ref.read(productSearchProvider.notifier).search,
decoration: InputDecoration(hintText: l10n.searchHint),
),
Expanded(
child: results.when(
data: (items) => ListView.builder(
itemCount: items.length,
itemExtent: 72,
itemBuilder: (context, i) => ProductTile(
key: ValueKey<String>(items[i].id),
product: items[i],
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ErrorView(error: e as ApiError),
),
),
],
);
}
}
Get the Full Pack
These eight rules cover the Flutter patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — null-safe, const-propagated, stateless-first, Riverpod-wired, async-disciplined, key-stable, platform-aware, harness-tested Flutter, without having to re-prompt.
If you want the expanded pack — these eight plus rules for melos monorepos, platform-channel authoring, freezed + json_serializable conventions, go_router with typed routes, integration tests that run on CI, signal-free animation patterns, and the testing conventions I use on production Flutter apps — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Flutter you would actually merge.
Top comments (0)