01 — Foundation
The Four Layers
Dependencies flow strictly inward. The domain layer has zero Flutter imports — pure Dart, fully unit-testable on any machine.
02 — File Structure
Feature-first Organisation
Features own their slice of each layer. Core infrastructure is shared. Scale by adding feature folders, not by reshaping the architecture.
03 — Code Patterns
Implementation Blueprints
Every pattern below is production-ready and wired to a real package. No prototypes.
sealed class AuthEvent {} final class LoginRequested extends AuthEvent { final String email; final String password; const LoginRequested({ required this.email, required this.password, }); } final class LogoutRequested extends AuthEvent {} final class SessionRestored extends AuthEvent {}
sealed class AuthState extends Equatable { const AuthState(); } final class AuthInitial extends AuthState { @override List get props => []; } final class AuthLoading extends AuthState { @override List get props => []; } final class Authenticated extends AuthState { final User user; // signals offline-first cache source final bool isFromCache; const Authenticated(this.user, {this.isFromCache = false}); @override List get props => [user, isFromCache]; } final class AuthFailure extends AuthState { final Failure failure; const AuthFailure(this.failure); @override List get props => [failure]; }
@injectable class AuthBloc extends Bloc<AuthEvent, AuthState> { final LoginUseCase _login; final LogoutUseCase _logout; AuthBloc(this._login, this._logout) : super(AuthInitial()) { on<LoginRequested>(_onLogin); on<LogoutRequested>(_onLogout); on<SessionRestored>(_onRestore); } Future<void> _onLogin( LoginRequested e, Emitter<AuthState> emit, ) async { emit(AuthLoading()); final result = await _login( LoginParams(email: e.email, password: e.password)); // fpdart Either — no try/catch needed result.fold( (f) => emit(AuthFailure(f)), (u) => emit(Authenticated(u)), ); } }
class LoginPage extends StatelessWidget { @override Widget build(BuildContext context) => BlocConsumer<AuthBloc, AuthState>( listener: (ctx, state) { if (state is Authenticated) context.go('/home'); if (state is AuthFailure) _showError(ctx, state.failure); }, // Exhaustive switch — Dart compiler // warns if a state case is missing builder: (ctx, state) => switch (state) { AuthLoading() => const LoadingView(), AuthFailure(:final failure) => LoginForm(error: failure), _ => const LoginForm(), }, ); }
// Single Responsibility: one purpose per class // Dependency Inversion: depends on abstract repo @injectable class LoginUseCase { final AuthRepository _repo; LoginUseCase(this._repo); // callable — KISS single entry point Future<Either<Failure, User>> call( LoginParams p, ) => _repo.login(p.email, p.password); } // Domain contract — no Dio, no Drift abstract class AuthRepository { Future<Either<Failure, User>> login( String email, String password); Future<Either<Failure, Unit>> logout(); }
@injectable class SearchBloc extends Bloc<SearchEvent, SearchState> { final SearchUseCase _search; final _debouncer = Debouncer( delay: const Duration(milliseconds: 350), ); SearchBloc(this._search) : super(SearchInitial()) { on<QueryChanged>((e, _) { _debouncer.run(() => add(ExecuteSearch(e.q))); }); on<ExecuteSearch>((e, emit) async { emit(SearchLoading()); final r = await _search(SearchParams(e.q)); emit(r.fold(SearchError.new, SearchLoaded.new)); }); } @override Future<void> close() { _debouncer.dispose(); return super.close(); } }
@module abstract class NetworkModule { @lazySingleton Dio dio( AuthInterceptor auth, EncryptInterceptor encrypt, LoggerInterceptor logger, ) { final dio = Dio(BaseOptions( baseUrl: Env.apiUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), headers: {'Accept': 'application/json'}, )); dio.interceptors.addAll([auth, encrypt, logger]); return dio; } }
@lazySingleton class EncryptInterceptor extends Interceptor { final EncryptionService _enc; EncryptInterceptor(this._enc); @override void onRequest(RequestOptions o, RequestInterceptorHandler h) { if (o.data != null) { final iv = IV.fromSecureRandom(16); o.data = { 'iv': iv.base64, 'payload': _enc.encrypt( jsonEncode(o.data), iv: iv).base64, }; } h.next(o); } @override void onResponse(Response r, ResponseInterceptorHandler h) { final d = r.data; if (d is Map && d.containsKey('payload')) { r.data = jsonDecode(_enc.decrypt( Encrypted.fromBase64(d['payload']), iv: IV.fromBase64(d['iv']), )); } h.next(r); } }
@lazySingleton class AuthRemoteDs { final Dio _dio; CancelToken? _activeToken; AuthRemoteDs(this._dio); Future<UserDto> login( String email, String password) async { // Cancel any in-flight request first _activeToken?.cancel('superseded'); _activeToken = CancelToken(); final res = await _dio.post( '/auth/login', data: {'email': email, 'password': password}, cancelToken: _activeToken, ); return UserDto.fromJson(res.data); } void dispose() => _activeToken?.cancel(); }
@lazySingleton class AuthInterceptor extends QueuedInterceptorsWrapper { final SecureStorage _storage; final Dio _refreshDio; // separate, no interceptors @override void onRequest(RequestOptions o, RequestInterceptorHandler h) async { final token = await _storage.getAccessToken(); if (token != null) o.headers['Authorization'] = 'Bearer $token'; h.next(o); } @override void onError(DioException e, ErrorInterceptorHandler h) async { if (e.response?.statusCode == 401) { final newToken = await _refresh(); if (newToken != null) { e.requestOptions.headers['Authorization'] = 'Bearer $newToken'; return h.resolve(await _retry(e.requestOptions)); } } h.next(e); } }
@lazySingleton class SecureStorage { final _s = const FlutterSecureStorage( aOptions: AndroidOptions( encryptedSharedPreferences: true, keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_OAEPwithSHA_256_and_MGF1Padding, ), iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock, ), ); static const _kToken = 'access_token'; static const _kRefresh = 'refresh_token'; static const _kUserId = 'user_id'; Future<void> saveTokens(String access, String refresh) => Future.wait([ _s.write(key: _kToken, value: access), _s.write(key: _kRefresh, value: refresh), ]); Future<String?> getAccessToken() => _s.read(key: _kToken); Future<void> clearAll() => _s.deleteAll(); }
class Debouncer { final Duration delay; Timer? _timer; Debouncer({required this.delay}); void run(VoidCallback fn) { _timer?.cancel(); _timer = Timer(delay, fn); } void dispose() => _timer?.cancel(); } // In BLoC.on<QueryChanged>: // _debouncer.run(() => add(ExecuteSearch(e.q))); // On BLoC.close(): _debouncer.dispose();
@LazySingleton(as: ProductRepository) class ProductRepositoryImpl implements ProductRepository { final ProductRemoteDs _remote; final ProductLocalDs _local; // Isar ProductRepositoryImpl(this._remote, this._local); @override Future<Either<Failure, List<Product>>> getProducts() async { try { final dtos = await _remote.fetchProducts(); // write-through: always update local cache await _local.upsertAll(dtos); return Right(dtos.map((d) => d.toDomain()).toList()); } on DioException { // network unavailable — serve cache final cached = await _local.getAll(); if (cached.isNotEmpty) return Right(cached.map((d) => d.toDomain()).toList()); return const Left(NetworkFailure()); } on Exception catch (e) { return Left(UnknownFailure(e.toString())); } } }
@collection class ProductDto { Id? isarId; @Index(unique: true) late String id; late String name; late double price; late String imageUrl; late int cachedAt; // unix ms — for TTL Product toDomain() => Product(id: id, name: name, price: price, imageUrl: imageUrl); } @lazySingleton class ProductLocalDs { final Isar _isar; ProductLocalDs(this._isar); Future<void> upsertAll(List<ProductDto> dtos) => _isar.writeTxn(() => _isar.productDtos.putAll(dtos)); Future<List<ProductDto>> getAll() => _isar.productDtos.where().findAll(); }
sealed class Failure { const Failure(); } final class NetworkFailure extends Failure { const NetworkFailure(); } final class UnauthorizedFailure extends Failure { const UnauthorizedFailure(); } final class ServerFailure extends Failure { final int statusCode; final String message; const ServerFailure({required this.statusCode, required this.message}); } final class CacheFailure extends Failure { const CacheFailure(); } final class UnknownFailure extends Failure { final String message; const UnknownFailure(this.message); }
@lazySingleton class SttService { final _stt = SpeechToText(); final _words = StreamController<SttResult>.broadcast(); Stream<SttResult> get results => _words.stream; bool get isListening => _stt.isListening; Future<bool> initialize() => _stt.initialize(onError: _onError); Future<void> startListening({ String localeId = 'en-US', }) async { if (!await initialize()) return; _stt.listen( onResult: (r) => _words.add(SttResult( text: r.recognizedWords, isFinal: r.finalResult, confidence: r.confidence, )), localeId: localeId, listenOptions: SpeechListenOptions( partialResults: true, cancelOnError: false, ), ); } Future<void> stop() => _stt.stop(); void dispose() { _stt.cancel(); _words.close(); } }
@lazySingleton class TtsService { final _tts = FlutterTts(); Future<void> init() async { await _tts.setLanguage('en-US'); await _tts.setSpeechRate(0.95); await _tts.setVolume(1.0); await _tts.setPitch(1.0); // On Android: use Google TTS engine if (Platform.isAndroid) { await _tts.setEngine('com.google.android.tts'); } } Future<void> speak(String text) async { await _tts.stop(); await _tts.speak(text); } Future<void> stop() => _tts.stop(); Future<void> dispose() => _tts.stop(); }
@lazySingleton class WsClient { WebSocketChannel? _channel; final _messages = StreamController<Map<String, dynamic>>.broadcast(); int _retryCount = 0; bool _disposed = false; Stream<Map<String, dynamic>> get messages => _messages.stream; void connect(String url, String token) { _channel = WebSocketChannel.connect( Uri.parse('$url?token=$token'), ); _channel!.stream.listen( (d) { _retryCount = 0; _messages.add(jsonDecode(d as String)); }, onDone: () => _reconnect(url, token), onError: (_) => _reconnect(url, token), ); } void _reconnect(String url, String token) { if (_disposed || _retryCount >= 5) return; final delay = Duration( seconds: pow(2, _retryCount).toInt()); _retryCount++; Future.delayed(delay, () => connect(url, token)); } void send(Map payload) => _channel?.sink.add(jsonEncode(payload)); void dispose() { _disposed = true; _channel?.sink.close(); _messages.close(); } }
@injectable class ChatBloc extends Bloc<ChatEvent, ChatState> { final WsClient _ws; StreamSubscription? _sub; ChatBloc(this._ws) : super(ChatInitial()) { on<ChatStarted>((_, __) { _sub = _ws.messages.listen( (msg) => add(MessageReceived(msg)), onError: (_) => add(ChatErrored()), ); }); on<MessageReceived>((e, emit) { if (state is ChatLoaded) { final msgs = [...(state as ChatLoaded).messages, e.message]; emit(ChatLoaded(msgs)); } }); on<MessageSent>((e, _) => _ws.send({'type': 'message', 'text': e.text})); } @override Future<void> close() { _sub?.cancel(); return super.close(); } }
class AnimatedListItem extends StatefulWidget { final Widget child; final int index; // drives stagger delay const AnimatedListItem({ required this.child, required this.index, super.key }); } class _State extends State<AnimatedListItem> with SingleTickerProviderStateMixin { late final _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 420)); late final _curve = CurvedAnimation( parent: _ctrl, curve: Curves.easeOutCubic); late final _slide = Tween( begin: const Offset(0, .18), end: Offset.zero) .animate(_curve); @override void initState() { super.initState(); Future.delayed( Duration(milliseconds: widget.index * 55), () { if (mounted) _ctrl.forward(); }); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext _) => FadeTransition(opacity: _curve, child: SlideTransition( position: _slide, child: widget.child)); }
// Source: product list card Hero( tag: 'product-img-${product.id}', child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network(product.imageUrl, fit: BoxFit.cover), ), ) // Destination: product detail SliverAppBar SliverAppBar( expandedHeight: 340, pinned: true, flexibleSpace: FlexibleSpaceBar( background: Hero( tag: 'product-img-$id', child: Image.network(imageUrl, fit: BoxFit.cover), ), ), ) // app.dart — required for go_router + Hero builder: (ctx, child) => HeroControllerScope( controller: MaterialApp .createMaterialHeroController(), child: child!, ),
// Fade + subtle upward slide on push GoRoute( path: '/product/:id', pageBuilder: (ctx, state) => CustomTransitionPage( key: state.pageKey, child: ProductDetailPage( id: state.pathParameters['id']!), transitionsBuilder: (_, anim, __, child) { final tween = Tween( begin: const Offset(0, 0.04), end: Offset.zero, ).animate(CurvedAnimation( parent: anim, curve: Curves.easeOutCubic)); return FadeTransition( opacity: anim, child: SlideTransition( position: tween, child: child)); }, ), ),
class LottieView extends StatefulWidget { final String assetPath; final bool repeat; final double? width; const LottieView({ required this.assetPath, this.repeat = false, this.width, super.key }); } class _S extends State<LottieView> with SingleTickerProviderStateMixin { late final _ctrl = AnimationController(vsync: this); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(_) => Lottie.asset( widget.assetPath, width: widget.width, controller: _ctrl, onLoaded: (c) { _ctrl.duration = c.duration; widget.repeat ? _ctrl.repeat() : _ctrl.forward(); }, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ); }
abstract final class AppColors { // ── Brand ────────────────────────────── static const primary = Color(0xFF6750A4); static const secondary = Color(0xFF625B71); static const tertiary = Color(0xFF7D5260); static const error = Color(0xFFB3261E); // ── Semantic ─────────────────────────── static const success = Color(0xFF1B5E20); static const warning = Color(0xFFF57F17); static const info = Color(0xFF01579B); // ── Surfaces ─────────────────────────── static const surfaceLight = Color(0xFFFEF7FF); static const surfaceDark = Color(0xFF141218); // Zero hardcoded colors outside this file }
// Enforce 4-pt grid. Zero magic numbers // anywhere outside this file. abstract final class Sp { static const double xs = 4; static const double sm = 8; static const double md = 16; static const double lg = 24; static const double xl = 32; static const double xxl = 48; static const h4 = SizedBox(height: xs); static const h8 = SizedBox(height: sm); static const h16 = SizedBox(height: md); static const h24 = SizedBox(height: lg); static const w8 = SizedBox(width: sm); static const w16 = SizedBox(width: md); static const p16 = EdgeInsets.all(md); static const p24 = EdgeInsets.all(lg); static const ph16 = EdgeInsets.symmetric(horizontal: md); } // Usage: Column(children: [card1, Sp.h16, card2]) Padding(padding: Sp.p24, child: content)
abstract final class AppTheme { static final light = ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.light, ), textTheme: AppTypography.textTheme, scaffoldBackgroundColor: AppColors.surfaceLight, cardTheme: CardTheme( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( color: Colors.grey.withOpacity(0.15)), ), ), appBarTheme: const AppBarTheme( elevation: 0, scrolledUnderElevation: 0.5, ), ); static final dark = ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.dark, ), textTheme: AppTypography.textTheme, scaffoldBackgroundColor: AppColors.surfaceDark, ); }
04 — Data Flows
Every Path Traced
See exactly how data moves through each layer for every scenario.
Happy path — UI to API and back
Response → UI + write-through cache
Offline path — network unavailable
Search with debounce + cancel
Real-time WebSocket message
Token lifecycle — 401 auto-refresh
05 — Feature Matrix
Every Requirement Mapped
All 10 features from the original brief — each with the right package, the right layer, the right pattern.
speech_to_text ^7.x
flutter_tts ^4.x
encrypt ^5.x
dio ^5.x
dart:async — zero deps
isar ^4.x
isar_flutter_libs
flutter_secure_storage ^9.x
shimmer ^3.x
lottie ^3.x
go_router ^14.x
web_socket_channel ^3.x
flutter/material.dart — built-in
get_it ^8.x
injectable ^2.x
06 — Principles
SOLID + KISS Applied
Not abstract rules — concrete decisions this architecture makes because of these principles.
07 — Package Registry
The Right Tool Every Time
Curated by stability, pub.dev score, and active maintenance. No trendy packages — proven production picks only.
✦ — AI Scaffold
Generate with AI
Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.
You are a senior Flutter architect. Scaffold a production-ready Flutter app using Clean Architecture. Stack: - Dart: 3.x (sound null safety) - State: flutter_bloc (Cubit + BLoC) - DI: get_it + injectable - Navigation: go_router with typed routes - Network: dio + retrofit (code-gen) - Local: drift (SQLite) + shared_preferences + flutter_secure_storage - Reactive: rxdart for complex streams - Image: cached_network_image - Testing: mocktail, bloc_test, patrol (E2E) Provide: 1. Feature-first folder structure (feature/auth, feature/home, core/*) 2. Clean Architecture layers (data/domain/presentation) per feature 3. BLoC pattern with state freezed union types 4. Repository + DataSource pattern (remote + local) 5. Retrofit API client generation setup 6. Drift database schema + DAO pattern 7. go_router configuration with auth guard 8. Unit test for BLoC and Repository using mocktail