Architecting Flutter Clean Apps
Markdown--- name: architecting-flutter-clean-apps description: Generates, audits, and enforces enterprise-grade Feature-First Clean Architecture for Flutter apps using Riverpod, GoRouter, fpdart/Either functional error handling, Supabase, Superwall, and PostHog. Use when building production Flutter apps, scaffolding new features, reviewing Flutter architecture, or when the prompt includes "go-setup" to trigger full boilerplate generation. --- # Flutter Enterprise Clean Architecture
When triggered by go-setup, scaffold immediately in this order:
- Directory topology (Section: Directory Topology)
pubspec.yamldependencies- Core files:
failures.dart,usecase.dart,color_generator.dart - Services:
backend_service.dart,analytics_service.dart,paywall_service.dart,notification_service.dart - Onboarding funnel + account screen
- Wire routes in
app_router.dart
Do not ask which pieces to build — build all of them, in this sequence, using the exact code templates below.
Progress checklist for any new feature or full go-setup run:
- [ ] Confirm/scaffold directory structure under lib/{app,core,features,shared}
- [ ] Add/verify pubspec.yaml dependencies
- [ ] Deploy core/errors/failures.dart + core/utils/usecase.dart
- [ ] Deploy core/theme/color_generator.dart with seed color
- [ ] Deploy core/services/* (backend, analytics, paywall, notifications)
- [ ] For each feature: create data/domain/presentation subfolders
- [ ] Domain layer: entities, repository interfaces, usecases (return Either<Failure, T>)
- [ ] Data layer: models with toEntity(), datasources, repository impls that catch exceptions → Failure
- [ ] Presentation layer: Riverpod controllers consuming usecases only (never repositories directly)
- [ ] Wire routes into app/routes/app_router.dart
- [ ] Generate onboarding funnel screen if new app
- [ ] Generate account screen with deletion flow (Apple 5.1.1 compliance)
- [ ] Run build_runner for riverpod_generator codegen
Directory Topology
lib/
├── app/ # entrypoint, bootstrap, routes (app_routes.dart, app_router.dart, route_guards.dart)
├── core/
│ ├── constants/
│ ├── errors/ # exceptions.dart, failures.dart
│ ├── network/
│ ├── services/ # analytics, backend, cache, deep_link, haptic, notification, paywall, remote_config, sync
│ ├── theme/ # color_generator.dart, theme_controller.dart, typography.dart
│ └── utils/ # either.dart, usecase.dart
├── features/<feature_name>/
│ ├── data/{datasources,models,repositories}/
│ ├── domain/{entities,repositories,usecases}/
│ └── presentation/{controllers,screens,widgets}/
└── shared/{components,l10n}/
- Domain: pure Dart only — no Flutter imports, no Supabase imports. Every fallible operation returns
Future<Either<Failure, T>>orStream<Either<Failure, T>>. - Data: catches all raw exceptions (
PostgrestException,SocketException, etc.) at the repository implementation boundary and maps them to aFailuresubtype. Models implementtoEntity(). - Presentation: controllers call UseCases, never repositories directly.
ref.watch()drives UI; no business logic insidebuild().
Failures & UseCase Contract
DART// lib/core/errors/failures.dart import 'package:equatable/equatable.dart'; abstract class Failure extends Equatable { final String message; final int? statusCode; const Failure(this.message, [this.statusCode]); List<Object?> get props => [message, statusCode]; } class ServerFailure extends Failure { const ServerFailure([super.message = 'A server error occurred. Please try again.', super.statusCode]); } class AuthFailure extends Failure { const AuthFailure([super.message = 'Authentication failed.', super.statusCode]); } class CacheFailure extends Failure { const CacheFailure([super.message = 'Unable to read or write local storage.']); } class NetworkFailure extends Failure { const NetworkFailure([super.message = 'No internet connection detected.']); }
DART// lib/core/utils/usecase.dart import 'package:fpdart/fpdart.dart'; import '../errors/failures.dart'; abstract interface class UseCase<Type, Params> { Future<Either<Failure, Type>> call(Params params); } abstract interface class StreamUseCase<Type, Params> { Stream<Either<Failure, Type>> call(Params params); } class NoParams { const NoParams(); }
Theme Engine (M3 Seed + OLED Pitch-Black)
Use AppThemeGenerator.generate(primaryColor: seed, brightness: ..., oledPitchBlack: true). Default seed #4A6CF7. Buttons: 52px min height, 14px radius, zero elevation. Cards: surfaceContainer, 16px radius, hairline outline at 15% opacity.
Supabase Static Facade (AppBackend)
Expose as static methods, never instantiate: currentUser, isAuthenticated, authStateChanges, signInWithApple(), signInWithGoogle(), signInWithOtp(), signOut(), deleteAccount() (invokes delete-user-account edge function — required for Apple Guideline 5.1.1), streamCollection(), invokeFunction().
Growth Stack
AnalyticsServicewraps PostHog:identify(),track(),screen(). Firetrack()on every onboarding step and monetization event.PaywallServicewraps Superwall:init(),registerPlacement(placement, params, onFeatureUnlocked, onDismissed),restorePurchases().NotificationServicewraps FCM + flutter_local_notifications, high-importance Android channel, foreground message → local notification bridge.
Reject standard slider-carousel onboarding. Always build a progressive micro-conversational funnel:
- Pain-point selection (chip/button list) → auto-advance on tap
- Quantified impact calculator (slider + derived stat, e.g. "hours lost/year")
- Commitment screen → synthetic processing delay (~2.5–3s, "Building your tailored plan...") → paywall trigger before auth
Key rule: monetization precedes registration. Track every step transition via AnalyticsService.track('onboarding_step_completed', {'step': n}). See full OpalOnboardingScreen implementation for exact widget structure — replicate this pattern for any onboarding request rather than a PageView carousel.
Every generated app MUST include an Account screen with:
- Current identity display (email or "Anonymous Session")
- "Restore Purchases" →
PaywallService.restorePurchases() - Privacy Policy + Terms of Service links
- Delete Account with confirmation dialog warning of irreversibility → calls
AppBackend.deleteAccount()→ navigates to onboarding/login
Never ship a paywall-monetized app without this screen wired into routes.
Example 1:
Input: go-setup
Output: Full lib/ tree scaffolded per Directory Topology; pubspec.yaml updated with the fixed dependency block; failures.dart, usecase.dart, color_generator.dart, all four core services, opal_onboarding_screen.dart, account_screen.dart generated and wired into app_router.dart.
Example 2:
Input: "Add a habits feature with CRUD against Supabase"
Output: features/habits/domain/entities/habit.dart (pure entity), domain/repositories/habit_repository.dart (abstract, returns Either<Failure, T>), domain/usecases/{get_habits,create_habit,delete_habit}.dart, data/models/habit_model.dart (with toEntity()), data/datasources/habit_remote_datasource.dart (Supabase calls, throws raw exceptions), data/repositories/habit_repository_impl.dart (catches exceptions → maps to ServerFailure/NetworkFailure), presentation/controllers/habits_controller.dart (@riverpod AsyncNotifier calling usecases), presentation/screens/habits_screen.dart.
Example 3:
Input: "Review this repository implementation" (contains a raw try { return await api.call() } catch (e) { throw e; } with no Either)
Output: Flag violation — data layer leaking raw exceptions into presentation. Rewrite to catch specific exception types and return Left(ServerFailure(e.message)), wrap success in Right(model.toEntity()).
- Always generate domain entities as immutable,
Equatable-extending classes with no JSON/Supabase awareness. - Repository implementations are the only place
try/catchon raw SDK exceptions is allowed. - Use
@riverpodcodegen annotations over manualStateNotifierProviderboilerplate. - Default OLED pitch-black surfaces for dark mode unless the user specifies a different dark palette.
- Route guards (
route_guards.dart) handle auth/onboarding redirects — never scatterif (!authenticated)checks inside screen widgets. - Every monetization trigger point must fire a corresponding
AnalyticsService.track()call.
- Don't let
UseCase/domain code importpackage:flutter/material.dartorpackage:supabase_flutter. - Don't call repositories directly from widgets or controllers — always go through a UseCase.
- Don't build multi-page
PageViewonboarding carousels — use the Opal conversational funnel pattern instead. - Don't ship a paywall without a visible "Restore Purchases" and account deletion path (App Store rejection risk).
- Don't put business logic inside
build()methods — compute state in controllers, watch results in the view. - Don't skip mapping raw exceptions to
Failuretypes — no unchecked exceptions may reach the presentation layer.