Flutter + Dart
Project context
This is a Flutter app targeting iOS and Android (with optional web). We use Riverpod for state management, freezed for immutable models, go_router for navigation, and Dart 3 with sound null safety throughout.
Stack
- Dart 3.4+
- Flutter 3.22+ (stable channel)
- Riverpod 2.x for state management (
flutter_riverpod) - freezed + json_serializable for models
- go_router for navigation
- dio (or http) for HTTP
- Material 3 theming
- flutter_test + integration_test for tests
- Mocktail for mocks
Folder structure
lib/
main.dart
app.dart — root MaterialApp.router
config/
theme.dart
env.dart
router/
app_router.dart — go_router config
features/
auth/
data/
domain/
presentation/
controllers/
widgets/
screens/
posts/
data/
post_repository.dart
post_api.dart
domain/
post.dart — freezed model
presentation/
controllers/
widgets/
screens/
shared/
widgets/
extensions/
test/
integration_test/
One folder per feature. Each feature has data/ (repos, APIs), domain/ (models, pure logic), presentation/ (UI + controllers).
Widgets
- StatelessWidget when possible; StatefulWidget only when truly needed
- Prefer ConsumerWidget / HookConsumerWidget when the widget reads providers
- Pass values down via constructor; use
.copyWithinstead of mutation - Extract any widget over ~80 lines into its own file
class PostList extends ConsumerWidget {
const PostList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final posts = ref.watch(postsProvider);
return posts.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (_, i) => PostTile(post: list[i]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
);
}
}
State management with Riverpod
- One provider per piece of state
- Use
Notifier/AsyncNotifier(the modern API) — notStateNotifier(legacy) - Use code-generation:
@riverpodannotation + build_runner - Keep providers small; compose via
ref.watch(...)
@riverpod
class Posts extends _$Posts {
@override
Future<List<Post>> build() async {
final repo = ref.watch(postRepositoryProvider);
return repo.list();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(postRepositoryProvider).list());
}
}
Models with freezed
- All domain models are freezed unions or data classes
- Use
@freezed+fromJson/toJsonvia json_serializable - Use sealed unions for state machines and result types
@freezed
class Post with _$Post {
const factory Post({
required String id,
required String title,
required String body,
required DateTime createdAt,
}) = _Post;
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}
After editing freezed models: dart run build_runner build --delete-conflicting-outputs
Navigation
- go_router for declarative routing
- Define routes in one place:
app_router.dart - Use typed routes (
GoRouteData+routesannotation) for compile-time route safety - Use
context.go(...)for replace;context.push(...)for stack push - Never
Navigator.of(context).push(...)— bypasses go_router's URL state
Async and error handling
AsyncValue<T>from Riverpod — use.when(data:, loading:, error:)- Don't
.then((v) {})— useawaitalways - Network errors at the data layer; UI catches at the controller / provider level
- Never
try { ... } catch (e) {}empty — always re-throw or log
Theming
- Define one
ThemeDatainconfig/theme.dart - Use Material 3 (
useMaterial3: true) — it's the default in Flutter 3.16+ - Define
ColorScheme.fromSeed(seedColor: ...)for both light and dark - Use
Theme.of(context).colorScheme.primary— never raw hex in widgets
Patterns to follow
constconstructors everywhere possible — Flutter rebuilds aggressively; const helps- Extract magic numbers to a
Sizes/Spacingclass - Use
Form+TextFormField+validator— not custom error tracking - Localize strings with
flutter_localizationsfrom day one if i18n is on the roadmap
Patterns to avoid
StatefulWidgetfor state that should be in a providersetStatefor cross-screen state — use Riverpod- Calling
ref.readinbuild— useref.watch;ref.readis for callbacks - Mutating freezed instances — use
.copyWith - Hardcoded colors / sizes in widgets — use theme tokens / spacing constants
print()for debugging — usedebugPrintor a proper logger- Long methods in
build— extract widgets
Testing
- Widget tests for screens (
testWidgets(...)) - Unit tests for controllers and repos
- Integration tests for critical user flows (
integration_test/folder) - Use Mocktail for mocking; never mock concrete classes — mock abstractions
flutter test --coveragefor coverage; aim for meaningful coverage on logic, not widgets
Tooling
flutter run— runs on connected device / simulatorflutter build apk --release/flutter build ipa --releasedart run build_runner watch --delete-conflicting-outputs— for freezed / riverpod / json codegenflutter testdart format .flutter analyze
AI behavioral rules
- Default to Riverpod's
@riverpodNotifier API; neverStateNotifier(legacy) - All models are freezed — never plain Dart classes for data
- After editing freezed / riverpod / json_serializable code, run
build_runner build(don't hand-edit.g.dart/.freezed.dart) - Always add
constconstructors where possible - Use
ref.watchinbuild;ref.readonly in callbacks - Use
context.go/context.push, neverNavigatordirectly - Never mutate freezed instances; always
.copyWith - Run
flutter analyzeandflutter testbefore declaring a task done