DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Flutter: Production Rules for AI-Assisted Flutter and Dart Development

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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}'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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().
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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()),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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(),
);
Enter fullscreen mode Exit fullscreen mode

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
      ),
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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),
    ],
  ),
);
Enter fullscreen mode Exit fullscreen mode

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),
      ],
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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'}));
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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()),
      ),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
          ),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)