flutter-patterns · git:20260912.a10a790 · 2026-09-12 · sha256 d9ff36bb8d37c8b1
flutter-patterns git:20260912.a10a790A
Immutable. This exact content never changes and is served at /api/v1/blob/d9ff36bb8d37c8b1.
---
name: flutter-patterns
description: Provides production Flutter and Dart 3 patterns covering widget composition, state management choice (Riverpod, BLoC, plain ChangeNotifier), immutability with freezed and records, sealed-class state, async and streams, Firebase usage (Auth, Firestore, Functions), navigation, and widget/integration testing. Use when writing, reviewing, or architecting Flutter apps or Dart packages.
---
# Flutter Patterns
Small const widgets, immutable state modelled with sealed types, side effects in a thin layer
above the UI, and tests at the widget boundary. Dart 3 features (records, patterns, sealed
classes) are assumed.
## When to use
- Starting a Flutter feature and choosing how to structure widgets and state.
- Reviewing Dart for null safety, immutability, async correctness, or rebuild scope.
- Integrating Firebase services in a Flutter app.
- Writing widget, unit, or integration tests.
## Null safety and Dart idioms
```dart
// Avoid `!`; use `?.`, `??`, guards, or pattern matching
final name = user?.name ?? 'Guest';
String describe(User? user) => switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Guest',
};
// `late` only when initialisation is guaranteed before first read (initState)
late final AnimationController _controller;
```
- Prefer `final` everywhere; `var` only when reassignment is intended.
- `firstWhereOrNull` (package:collection) instead of `firstWhere` with a throwing `orElse`.
- Exhaustive `switch` expressions over `if` chains for sealed types and enums.
- Records for lightweight multi-value returns: `(int count, double total) summarise(...)`.
## Immutability
### Sealed classes for state
```dart
sealed class LoadState<T> {
const LoadState();
}
final class Loading<T> extends LoadState<T> { const Loading(); }
final class Loaded<T> extends LoadState<T> { const Loaded(this.data); final T data; }
final class Failed<T> extends LoadState<T> { const Failed(this.error); final Object error; }
Widget render(LoadState<List<Item>> state) => switch (state) {
Loading() => const CircularProgressIndicator(),
Loaded(:final data) => ItemList(items: data),
Failed(:final error) => ErrorView(message: '$error'),
};
```
The compiler enforces every branch; adding a state variant breaks every incomplete switch at
build time, which is exactly what you want.
### freezed for data classes
```dart
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
final updated = user.copyWith(name: 'New name'); // never mutate, always copy
```
Use freezed for models that cross a boundary (JSON, Firestore documents, state objects with
several fields). Use a plain `const` class with `final` fields for two- or three-field values,
and records for ad-hoc tuples. Run `dart run build_runner build --delete-conflicting-outputs`
after model changes; commit the generated files.
## Widget composition
**Extract to classes, not methods.** A `_buildHeader()` method rebuilds with its parent and
cannot be `const`; a `_Header` widget can be skipped by the framework.
```dart
class _Header extends StatelessWidget {
const _Header(this.title);
final String title;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
```
**Push rebuilds to the leaves.** The widget that watches state should be as small as possible.
```dart
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold(
body: Column(children: [
ExpensiveHeader(), // const: never rebuilt
_CounterText(), // only this watches the provider
ExpensiveFooter(),
]),
);
}
class _CounterText extends ConsumerWidget {
const _CounterText();
@override
Widget build(BuildContext context, WidgetRef ref) => Text('${ref.watch(counterProvider)}');
}
```
- `const` constructors and `const` child trees wherever the values are compile-time constants.
- Widgets take data and callbacks; they do not fetch, persist, or navigate on their own.
- Keep `build` free of side effects and heavy work; compute in the state layer.
- Use `Theme.of(context)` tokens, not hardcoded colours and sizes.
- `ListView.builder` / `SliverList` for anything longer than a screen; never a `Column` of
hundreds of children.
## State management
Pick one approach per app and hold to it.
| Situation | Choice |
|---|---|
| Local, ephemeral UI state (a toggle, a text field, an animation) | `StatefulWidget` + `setState` |
| App or feature state, most new apps | Riverpod (code-generated providers) |
| Teams that want explicit event -> state transitions and event logs | BLoC / Cubit |
| Tiny app or package with no dependency budget | `ChangeNotifier` + `ListenableBuilder` |
### Riverpod
```dart
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchAll();
}
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => const [];
void add(Product product) {
final existing = state.firstWhereOrNull((i) => i.productId == product.id);
state = existing == null
? [...state, CartItem(productId: product.id, quantity: 1)]
: [for (final i in state) i.productId == product.id ? i.copyWith(quantity: i.quantity + 1) : i];
}
}
@riverpod
int cartCount(Ref ref) => ref.watch(cartProvider).length; // derived, cached
```
`ref.watch` in build, `ref.read` in callbacks, `ref.listen` for one-shot reactions (snackbars,
navigation). Derived providers replace manual selectors.
### Cubit
```dart
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._auth) : super(const AuthState.initial());
final AuthService _auth;
Future<void> signIn(String email, String password) async {
emit(const AuthState.loading());
try {
emit(AuthState.authenticated(await _auth.signIn(email, password)));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
}
```
## Async and streams
```dart
// Concurrent futures with record destructuring
final (users, orders) = await (userRepo.fetchAll(), orderRepo.recent()).wait;
// Always guard BuildContext after an await
Future<void> _submit() async {
setState(() => _busy = true);
try {
await _service.save(_draft);
if (!mounted) return;
context.go('/done');
} on ServiceException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
// Streams: expose from the repository, consume declaratively
Stream<List<Message>> watchThread(String id) => _source.watch(id).map(Message.listFrom);
StreamBuilder<List<Message>>(
stream: repo.watchThread(threadId),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) => const Loader(),
AsyncSnapshot(:final error?) => ErrorView(message: '$error'),
AsyncSnapshot(:final data?) => MessageList(messages: data),
_ => const SizedBox.shrink(),
},
)
```
- Never store a `StreamSubscription` without cancelling it in `dispose`; prefer `StreamBuilder`
or a provider that manages the lifecycle.
- Wrap external failures in typed exceptions at the repository layer; the UI never catches
`FirebaseException` directly.
- Cancel or debounce user-driven async work (search-as-you-type) so stale results cannot
overwrite fresh ones.
## Firebase usage patterns
Keep Firebase behind repository interfaces so widgets and state classes depend on your types,
not on the SDK, and tests can substitute fakes.
```dart
abstract interface class NotesRepository {
Stream<List<Note>> watchAll(String uid);
Future<void> upsert(Note note);
}
class FirestoreNotesRepository implements NotesRepository {
FirestoreNotesRepository(this._db);
final FirebaseFirestore _db;
CollectionReference<Note> _notes(String uid) => _db
.collection('users').doc(uid).collection('notes')
.withConverter<Note>(
fromFirestore: (snap, _) => Note.fromJson({...snap.data()!, 'id': snap.id}),
toFirestore: (note, _) => note.toJson()..remove('id'),
);
@override
Stream<List<Note>> watchAll(String uid) => _notes(uid)
.orderBy('updatedAt', descending: true)
.snapshots()
.map((s) => s.docs.map((d) => d.data()).toList());
@override
Future<void> upsert(Note note) => _notes(note.ownerId).doc(note.id).set(note, SetOptions(merge: true));
}
```
- **Auth**: expose `authStateChanges()` as a provider; derive a sealed `AuthStatus` from it and
drive router redirects from that, not from widgets checking `currentUser` directly.
- **Firestore**: always `withConverter`; model documents with freezed; keep per-user data under
`users/{uid}/...` so security rules stay simple; paginate with `limit` + `startAfterDocument`;
never query unbounded collections into memory.
- **Security rules are the real authorisation layer.** Client checks are UX, not security. Test
rules with the emulator.
- **Cloud Functions**: call via `FirebaseFunctions.instance.httpsCallable('name')`, wrap the
result in a typed model, and map `FirebaseFunctionsException` codes to your own exceptions.
- **Emulators** for local development and integration tests: `firebase emulators:start`, then
`useAuthEmulator`, `useFirestoreEmulator` behind a `kDebugMode` or `--dart-define` flag.
- **Crashlytics**: hook `FlutterError.onError` and `PlatformDispatcher.instance.onError` in
`main()`; record handled exceptions from the repository layer with context.
## Navigation
Use `go_router` with a `refreshListenable` bound to the auth stream so redirects re-evaluate
when auth changes. Typed routes (`go_router_builder`) remove string paths from widgets. Keep
route definitions in one file; widgets call `context.go`/`context.push` with route objects.
## Error handling
```dart
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashReporter.recordFlutterError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashReporter.record(error, stack, fatal: true);
return true;
};
runApp(const ProviderScope(child: App()));
}
```
- Repository layer converts SDK exceptions into a small app exception hierarchy.
- State layer turns exceptions into `Failed` states; the UI renders them.
- Replace the red `ErrorWidget` in release builds with `ErrorWidget.builder`.
## Testing
```dart
// Unit: pure logic and notifiers, with fakes
test('cart total sums price times quantity', () {
final cart = Cart()..add(product(price: 250))..add(product(price: 250));
expect(cart.total, 500);
});
// Widget: render with overrides, assert on what the user sees
testWidgets('shows item count badge', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => FakeCart(count: 3))],
child: const MaterialApp(home: CartBadge()),
));
expect(find.text('3'), findsOneWidget);
});
// Golden: visual regression for design-system widgets
await expectLater(find.byType(PrimaryButton), matchesGoldenFile('primary_button.png'));
// Integration (integration_test/app_test.dart): a real flow on a device or emulator
testWidgets('sign in and reach home', (tester) async {
await tester.pumpWidget(const App());
await tester.enterText(find.byKey(const Key('email')), 'user@example.com');
await tester.tap(find.text('Sign in'));
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
});
```
- Fakes over mocks: a `FakeNotesRepository` backed by a list is easier to reason about than a
mock with stubbed calls.
- `pumpAndSettle` for animations; plain `pump` with a duration when something never settles
(an infinite spinner).
- Find by `Key`, semantics label, or visible text; not by widget type when the type is generic.
- Firebase-backed flows run against the emulator suite in `integration_test`, never production.
- Commands: `flutter test`, `flutter test --coverage`, `flutter test integration_test`,
`flutter test --update-goldens`.
## Anti-patterns
- `user!.name` where `user?.name ?? default` was possible.
- Widget-returning private methods instead of widget classes.
- `setState` for anything read outside the widget.
- `context` used after an `await` without a `mounted` check.
- Firestore queries or `FirebaseAuth.instance` calls inside `build`.
- A `Column` inside `SingleChildScrollView` for a long list.
- Catching `Exception` in the UI and showing `e.toString()`.
- Tests that pump the whole app to check one widget.
## Review checklist
- [ ] `dart analyze` clean; `flutter test` green.
- [ ] State is sealed/immutable; updates create new objects.
- [ ] Rebuild scope is minimal; `const` used where possible.
- [ ] Every `await` in a widget is followed by a `mounted` guard before using `context`.
- [ ] Firebase is behind repositories with `withConverter`; rules cover every access.
- [ ] Subscriptions and controllers are disposed.
- [ ] New behaviour has a widget or unit test that fails without it.
## Related skills
- `opm:tdd-workflow` - `flutter test` RED/GREEN cycle.
- `opm:verification-loop` - `dart analyze` and `flutter test` as the release gate.
<!-- Adapted from affaan-m/ecc (MIT) -->