Production Flutter Engineering

Clean Architecture Field Guide

A complete, opinionated reference for building scalable, offline-first, secure, and beautiful Flutter applications — from first principles to production.

Offline First BLoC State SOLID + KISS AES-256 Encrypted Google STT / TTS Real-time WS Shared Elements Cancellable Calls Design System
Presentation BLoC · Screens · Widgets · Router · Animations
↓ depends on ↓
Domain Use cases · Entities · Repository contracts
↓ depends on ↓
Data Repositories · Remote DS · Local cache · DTOs
↓ depends on ↓
Core DI · Network · Storage · Speech · Realtime · Theme

The Four Layers

Dependencies flow strictly inward. The domain layer has zero Flutter imports — pure Dart, fully unit-testable on any machine.

The golden rule: Dependency arrows point inward only. Presentation → Domain ← Data. Core is a horizontal dependency available to all layers. No layer imports anything from a layer above it.
P
Presentation
Everything Flutter sees. BLoC/Cubit for state. Screens consume state and dispatch events. Zero business logic here — only UI decisions like "is the button enabled?"
D
Domain
Pure Dart. Entities, use cases, and abstract repository interfaces. This is the heart of your app. If you can run this as a CLI, your architecture is sound.
D
Data
Implements the domain contracts. Remote datasources use Dio. Local datasources use Drift (SQLite) or Isar. DTOs map to/from domain entities.
C
Core
Cross-cutting infrastructure: DI container, network client factory, secure storage, interceptors, speech services, WebSocket client, theme tokens.

Feature-first Organisation

Features own their slice of each layer. Core infrastructure is shared. Scale by adding feature folders, not by reshaping the architecture.

root_app / lib /
core/
di/DI
injection.dart— get_it + injectable bootstrap
injection.config.dart— generated, never edit
network/Network
dio_client.dart— factory with base options
auth_interceptor.dart— attach + refresh Bearer token
encrypt_interceptor.dart— AES-256-GCM body encryption
logger_interceptor.dart
storage/Storage
secure_storage.dart— Keystore/Keychain wrapper
app_prefs.dart— shared_preferences for flags
speech/Speech
stt_service.dart— Google STT stream
tts_service.dart— Google TTS playback
realtime/WS
ws_client.dart— WebSocket + auto-reconnect
theme/Theme
app_theme.dart— M3 ThemeData light + dark
app_colors.dart
app_typography.dart
app_spacing.dart— 4-pt grid constants
error/
failure.dart— sealed Failure hierarchy
utils/
debouncer.dart
features/
auth/— repeat this pattern per feature
domain/Domain
entities/user.dart
repositories/auth_repository.dartabstract
usecases/login_usecase.dart
usecases/logout_usecase.dart
data/Data
datasources/auth_remote_ds.dartDio + CancelToken + encrypt
datasources/auth_local_ds.dartIsar / Drift cache
models/user_dto.dartDTO + toEntity() mapper
auth_repository_impl.dart
presentation/UI
bloc/auth_bloc.dart
bloc/auth_event.dart
bloc/auth_state.dart
pages/login_page.dart
widgets/login_form.dart
app/
app.dart— MaterialApp.router root
router.dart— go_router with guards + transitions
main.dart— bootstrap DI, run app

Implementation Blueprints

Every pattern below is production-ready and wired to a real package. No prototypes.

auth_event.dart — sealed events domain · pres
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 {}
auth_state.dart — sealed states pres
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];
}
auth_bloc.dart pres
@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)),
    );
  }
}
login_page.dart — BlocConsumer pres
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(),
      },
    );
}
login_usecase.dart — SOLID SRP domain
// 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();
}
search_bloc.dart — debounce pattern pres
@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();
  }
}
dio_client.dart — factory core
@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;
  }
}
encrypt_interceptor.dart — AES-256-GCM core
@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);
  }
}
auth_remote_ds.dart — cancellable calls data
@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();
}
auth_interceptor.dart — token refresh core
@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);
  }
}
secure_storage.dart — Keystore/Keychain core
@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();
}
debouncer.dart core · utils
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();
product_repository_impl.dart — offline first data
@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()));
    }
  }
}
product_local_ds.dart — Isar schema data
@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();
}
failure.dart — sealed failure hierarchy core · domain
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);
}
stt_service.dart — Google Speech-to-Text core
@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(); }
}
tts_service.dart — Google Text-to-Speech core
@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();
}
ws_client.dart — WebSocket + auto-reconnect core
@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();
  }
}
chat_bloc.dart — WebSocket BLoC pres
@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();
  }
}
animated_list_item.dart — stagger entry pres · widgets
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));
}
shared element transition — Hero pres
// 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!,
),
page transition — go_router custom pres · routing
// 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));
      },
    ),
),
lottie_view.dart — motion animations pres · widgets
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(),
  );
}
app_colors.dart — token system core · theme
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
}
app_spacing.dart — 4-pt grid core · theme
// 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)
app_theme.dart — M3 ThemeData core · theme
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,
  );
}

Every Path Traced

See exactly how data moves through each layer for every scenario.

Happy path — UI to API and back

Widget tap
BLoC.add(Event)
UseCase.call()
Repo contract
Repo impl
Dio + encrypt
API response

Response → UI + write-through cache

DTO received
DTO.toDomain()
Isar.writeTxn()
Right(entity)
emit(Loaded)
BlocBuilder

Offline path — network unavailable

DioException
catch block
Isar.getAll()
Right(cached)
emit(Loaded, isFromCache: true)

Search with debounce + cancel

TextField
QueryChanged
Debouncer.run()
— 350 ms →
cancel prev token
new Dio request

Real-time WebSocket message

WsClient.stream
jsonDecode
add(MessageReceived)
emit(ChatLoaded)
UI rebuilds

Token lifecycle — 401 auto-refresh

401 response
QueuedInterceptor
POST /auth/refresh
SecureStorage.save()
retry original

Every Requirement Mapped

All 10 features from the original brief — each with the right package, the right layer, the right pattern.

🎤
Google STT / TTS
On-device + cloud Speech-to-Text via streaming listener. Google TTS engine on Android. Both wrapped in singleton services, streamed to BLoC as events. Language-switchable at runtime.
speech_to_text ^7.x flutter_tts ^4.x
🔐
API transmission encryption
AES-256-GCM with random IV per request. Shared secret negotiated at app init. Dio interceptor handles transparently — zero encryption code in feature layer. Response decrypted automatically.
encrypt ^5.x
📡
Cancellable API calls
Dio CancelToken per datasource method. Previous token cancelled before each new call. BLoC.close() cancels all pending tokens. Handles rapid navigation and search supersession cleanly.
dio ^5.x
Debounced search
Timer-based Debouncer utility in core/utils. 350 ms default delay. QueryChanged event resets the timer; ExecuteSearch fires the API call. Works with CancelToken to discard stale results.
dart:async — zero deps
💾
Offline-first database cache
Write-through cache: remote success → upsert to Isar. Network failure → serve Isar cache. Isar is pure Dart, NoSQL, extremely fast. Collection schemas mirror domain entities. TTL field for staleness.
isar ^4.x isar_flutter_libs
🔑
Secure token storage
flutter_secure_storage backed by Android Keystore with EncryptedSharedPreferences, and iOS Keychain. Stores access token, refresh token, user ID. Regular SharedPreferences only for non-sensitive UI flags.
flutter_secure_storage ^9.x
Smooth UI animations
Staggered list entry with per-item 55 ms delay. AnimatedContainer for layout morphing. AnimatedSwitcher between states. Shimmer skeleton loading. All driven by AnimationController + CurvedAnimation.
shimmer ^3.x
🎬
Motion / Lottie animations
Lottie JSON for empty states, success/error feedback, onboarding. AnimationController drives play/pause/loop. Fallback static widget if load fails. Assets ship in assets/lottie/ folder.
lottie ^3.x
🔀
Shared element transitions
Hero widget on image + title in list cards, same tag on detail page. go_router requires HeroControllerScope in MaterialApp.router builder. Custom HeroFlightShuttleBuilder for eased morph curve.
go_router ^14.x
Real-time communication
WebSocketChannel with exponential backoff auto-reconnect (max 5 retries). Messages dispatched as BLoC events. StreamSubscription cancelled on BLoC.close(). SSE fallback via Dio's ResponseBody stream.
web_socket_channel ^3.x
🎨
Design system / theming
Material 3 ColorScheme.fromSeed. AppColors token class — zero hardcoded hex in features. AppTypography with full M3 text scale. Sp spacing constants enforce 4-pt grid. Light + dark ThemeData in AppTheme.
flutter/material.dart — built-in
🧩
Dependency injection
get_it service locator with injectable code generation. @lazySingleton, @injectable, @factoryMethod annotations. Zero constructor calls in feature code — getIt<T>() everywhere. Test override via getIt.reset().
get_it ^8.x injectable ^2.x

SOLID + KISS Applied

Not abstract rules — concrete decisions this architecture makes because of these principles.

S
Single Responsibility
LoginUseCase does one thing: call auth repo. AuthBloc handles one domain: auth state. EncryptInterceptor only encrypts. No class has two reasons to change.
O
Open / Closed
Add a new feature by adding a new feature folder. The domain layer is closed for modification — open for extension via new use cases. Interceptors are open to addition, never edited.
L
Liskov Substitution
Any AuthRepository implementation can replace another. In tests, FakeAuthRepository drops in without the BLoC knowing. The domain contracts enforce this boundary.
I
Interface Segregation
Repositories expose only what their use cases need. ProductRepository.getProducts() — not a god-object CRUD interface. Each use case sees only the method it calls.
D
Dependency Inversion
BLoC depends on LoginUseCase (abstract). LoginUseCase depends on AuthRepository (abstract). Nothing in domain or presentation knows Dio or Isar exist.
K
Keep It Simple
Debouncer is a Timer wrapper — 10 lines. Failure is sealed classes — no complex hierarchy. Sp is a const class — no library. The simplest solution that satisfies the constraint wins.

The Right Tool Every Time

Curated by stability, pub.dev score, and active maintenance. No trendy packages — proven production picks only.

Package Version Purpose Layer
flutter_bloc^9.0BLoC + Cubit state management — the industry standard for Flutterpres
go_router^14.0Declarative routing with auth guards, deep links, shell routes, page transitionspres
lottie^3.0Motion animations from Lottie JSON — empty states, feedback, onboardingpres
shimmer^3.0Skeleton loading shimmer for lists and cardspres
cached_network_image^3.4Disk-cached network images with placeholder + error builderspres
equatable^2.0Value equality for BLoC states and domain entitiesdomain
fpdart^1.1Either / Option / TaskEither — functional error handling, no try/catch in domaindomain
freezed^2.5Immutable data classes, sealed unions, copyWith — code-generateddomain
dio^5.7HTTP client with interceptors, CancelToken, multipart, retrydata
isar^4.0Pure Dart NoSQL embedded database — offline-first cache. Faster than Drift for document workloadsdata
json_serializable^6.8JSON ↔ DTO code generation — fromJson / toJsondata
encrypt^5.0AES-256-GCM symmetric encryption for API transmissioncore
flutter_secure_storage^9.2Android Keystore + iOS Keychain backed secure key-value storecore
get_it^8.0Service locator / DI container — the de-facto Flutter standardcore
injectable^2.5Annotation-driven code generation for get_it — zero boilerplate DIcore
speech_to_text^7.0Google Speech-to-Text — on-device and cloud, streaming partial resultscore
flutter_tts^4.0Google Text-to-Speech with engine selection, rate/pitch/volume controlcore
web_socket_channel^3.0WebSocket client — real-time bidirectional communicationcore
shared_preferences^2.3Lightweight key-value for non-sensitive flags (theme mode, onboarding done)core
connectivity_plus^6.0Network status stream — drives ConnectivityBloc for offline bannercore
bloc_test^10.0BLoC-aware unit testing — expect state sequences cleanlydev
mocktail^1.0Null-safe mocking for unit tests — no code generation requireddev
build_runner^2.4Code generation runner for injectable, freezed, json_serializable, isardev

Generate with AI

Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.

 Architecture Prompt
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
crafted with by Sam
 Copied to clipboard!