| name | flutter-state-management |
| description | Complete Flutter state management and architecture skill covering Bloc (Cubit vs Bloc, BlocProvider, BlocListener, BlocBuilder, BlocSelector, BlocConsumer), Riverpod (StateNotifierProvider, NotifierProvider, FutureProvider, StreamProvider, ref.watch/read/listen, autoDispose, family), GoRouter (declarative routing, deep links, redirect guards, ShellRoute for nested navigation, StatefulShellRoute, typed routes), Dio (interceptors, error handling, retry with dio_smart_retry, request cancellation, logging), Clean Architecture (data/domain/presentation layers, repository pattern, use cases, dependency injection), and testing (bloc_test, Riverpod test, widget test with ProviderScope, mocktail, unit testing repository/use cases). Designed for the Flutter Developer agent building production-grade Flutter applications. |
| license | MIT |
| metadata | {"author":"opencode-agent-kit","version":"1.0.0","target_agent":"flutter-developer","stack":["Dart 3","Flutter SDK","Material Design 3","Bloc","Riverpod","GoRouter","Dio","Clean Architecture","bloc_test","mocktail","flutter_test"]} |
Flutter State Management & Architecture
Target Agent: @flutter-developer
Stack: Dart 3 · Flutter SDK · Material Design 3 · Bloc · Riverpod · GoRouter · Dio · Clean Architecture · bloc_test · mocktail
Comprehensive reference for state management, networking, routing, architecture, and testing in production-grade Flutter applications. Covers the two dominant state management ecosystems (Bloc and Riverpod) along with supporting libraries for routing, networking, and testability.
Table of Contents
- Bloc Pattern
- Riverpod
- GoRouter
- Dio
- Clean Architecture
- Testing
New in this version: Auth Bridge Riverpod→GoRouter (§3.6), Derived/Computed Providers (§2.9)
1. Bloc Pattern
1.1 Cubit vs Bloc
| Aspect | Cubit | Bloc |
|---|
| Trigger | Functions/methods directly emit states | Events dispatched via add() |
| Traceability | Lower — no event log | Higher — every state change has an associated event |
| Boilerplate | Minimal — just a class with methods | More — requires separate Event and State classes |
| Use case | Simple UI-bound state (counter, form fields, toggle) | Complex business logic where tracking every action matters |
| Debounce/Throttle | Manual | Built-in via on<T>() transformer override |
| Testing | Directly call methods and check state | blocTest with event/state matchers |
When to use Cubit: Form validation, pagination offset, dark mode toggle, any simple counter/toggle state.
When to use Bloc: Authentication flow, checkout wizard, multi-step onboarding, real-time data sync.
1.2 Cubit Example
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
1.3 Bloc Example
// --- Event ---
sealed class AuthEvent {
const AuthEvent();
}
class LoginRequested extends AuthEvent {
final String email;
final String password;
const LoginRequested({required this.email, required this.password});
}
class LogoutRequested extends AuthEvent {
const LogoutRequested();
}
// --- State ---
sealed class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthLoading extends AuthState {
const AuthLoading();
}
class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
}
// --- Bloc ---
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase _loginUseCase;
AuthBloc(this._loginUseCase) : super(const AuthInitial()) {
on<LoginRequested>(_onLoginRequested, transformer: (events, mapper) =>
events.droppable()); // Prevent duplicate login spam
on<LogoutRequested>(_onLogoutRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
final result = await _loginUseCase(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
void _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) {
emit(const AuthInitial());
}
}
1.4 BlocProvider (Dependency Injection)
BlocProvider provides a Bloc/Cubit to the widget tree. It automatically disposes the bloc when the widget is removed.
// Per-route scoped (disposed when route pops)
BlocProvider(
create: (context) => CounterCubit(),
child: CounterScreen(),
);
// App-wide singleton
MaterialApp(
home: BlocProvider<AuthBloc>(
create: (context) => AuthBloc(sl()),
child: const AppShell(),
),
);
// Multi-provider for convenience
MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(create: (_) => AuthBloc(sl())),
BlocProvider<CounterCubit>(create: (_) => CounterCubit()),
BlocProvider<ThemeCubit>(create: (_) => ThemeCubit()),
],
child: const AppShell(),
);
1.5 BlocBuilder
Rebuilds the widget tree whenever the Bloc state changes. Use buildWhen to filter rebuilds.
BlocBuilder<CounterCubit, int>(
buildWhen: (previous, current) => current % 2 == 0, // Only rebuild on even numbers
builder: (context, count) {
return Text('Count: $count');
},
);
1.6 BlocListener
Executes side effects (navigation, snackbar, dialog) once per state change. Does not rebuild the widget.
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
switch (state) {
case AuthAuthenticated():
context.go('/home');
case AuthError(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
case _:
break;
}
},
child: const LoginForm(),
);
1.7 BlocConsumer = BlocBuilder + BlocListener
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) context.go('/home');
},
builder: (context, state) {
return switch (state) {
AuthLoading() => const CircularProgressIndicator(),
AuthError(:final message) => Text(message),
_ => const LoginForm(),
};
},
);
1.8 BlocSelector
Rebuilds only when a derived value from the state changes. More efficient than BlocBuilder when you only need a slice of state.
BlocSelector<CartBloc, CartState, int>(
selector: (state) => state.items.fold(0, (sum, item) => sum + item.quantity),
builder: (context, totalQuantity) {
return Badge(
label: Text('$totalQuantity'),
child: IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () {}),
);
},
);
1.9 Event Transformers
Control how events are processed (debounce, throttle, droppable, restartable, sequential).
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(const SearchInitial()) {
on<SearchQueryChanged>(_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 300)),
);
}
}
2. Riverpod
2.1 Provider Types Overview
| Provider | Use Case | Auto-Dispose? |
|---|
Provider | Synchronous dependencies (repos, config) | autoDispose variant |
NotifierProvider | Mutable synchronous state (replace StateNotifierProvider in newer Riverpod) | autoDispose variant |
StateNotifierProvider | Mutable synchronous state with StateNotifier | autoDispose variant |
FutureProvider | One-shot async data (fetch from API on page load) | autoDispose variant |
StreamProvider | Real-time data (WebSocket, Firestore stream) | autoDispose variant |
StateProvider | Simple mutable value (counter, text field) | autoDispose variant |
ChangeNotifierProvider | Interop with existing ChangeNotifier code | autoDispose variant |
Rule of thumb:
- Prefer
NotifierProvider for new code (cleaner API than StateNotifierProvider).
- Use
autoDispose for ephemeral screen-level state that should be garbage collected.
- Use
family to create parameterized providers (e.g., productProvider(id)).
2.2 NotifierProvider (Recommended for mutable state)
// --- Notifier ---
class CounterNotifier extends Notifier<int> {
@override
int build() => 0; // Initial value
void increment() => state = state + 1;
void decrement() => state = state - 1;
void reset() => state = 0;
}
// --- Provider ---
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
2.3 StateNotifierProvider (Legacy pattern)
// --- StateNotifier ---
class AuthNotifier extends StateNotifier<AuthState> {
final LoginUseCase _loginUseCase;
AuthNotifier(this._loginUseCase) : super(const AuthInitial());
Future<void> login(String email, String password) async {
state = const AuthLoading();
final result = await _loginUseCase(
LoginParams(email: email, password: password),
);
result.fold(
(failure) => state = AuthError(failure.message),
(user) => state = AuthAuthenticated(user),
);
}
void logout() => state = const AuthInitial();
}
// --- Provider ---
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.watch(loginUseCaseProvider));
});
2.4 FutureProvider
final productListProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
final repository = ref.watch(productRepositoryProvider);
return repository.getProducts();
});
Usage with AsyncValue:
final productsAsync = ref.watch(productListProvider);
return productsAsync.when(
data: (products) => ProductListView(products: products),
loading: () => const ShimmerList(),
error: (err, stack) => ErrorRetryWidget(
message: err.toString(),
onRetry: () => ref.invalidate(productListProvider),
),
);
2.5 StreamProvider
final messageStreamProvider = StreamProvider.autoDispose<List<Message>>((ref) {
final repo = ref.watch(chatRepositoryProvider);
return repo.messagesStream();
});
2.6 ref.watch / ref.read / ref.listen
| Method | Behavior | Where to use |
|---|
ref.watch(provider) | Rebuilds the widget when provider value changes | Inside build() of ConsumerWidget or Consumer |
ref.read(provider) | Reads the value once, does not rebuild | Inside callbacks (onPressed, initState-like logic) |
ref.listen(provider, callback) | Runs a side effect when value changes (snackbar, navigation) | For one-shot side effects (snackbar, push route) |
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// watch: rebuilds when auth state changes
final authState = ref.watch(authProvider);
return Scaffold(
body: authState.when(
data: (state) => switch (state) {
AuthInitial() => LoginForm(
onLogin: (email, password) {
// read: one-shot, no rebuild needed
ref.read(authProvider.notifier).login(email, password);
},
),
AuthLoading() => const LoadingOverlay(),
AuthError(:final message) => ErrorCard(message: message),
AuthAuthenticated() => const HomeScreen(),
},
loading: () => const SplashScreen(),
error: (e, _) => ErrorCard(message: e.toString()),
),
);
}
}
Using ref.listen for side effects:
class AuthListener extends ConsumerWidget {
const AuthListener({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<AsyncValue<AuthState>>(authProvider, (previous, next) {
next.whenOrNull(
data: (state) {
if (state is AuthAuthenticated) {
context.go('/home');
}
},
);
});
// Return the actual UI tree
return const SizedBox.shrink();
}
}
2.7 Provider Modifiers
| Modifier | Purpose | Example |
|---|
.autoDispose | Dispose state when no longer watched | FutureProvider.autoDispose |
.family | Parameterize a provider | productProvider(id) |
Family example:
final productProvider = FutureProvider.autoDispose.family<Product, String>((ref, id) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getProduct(id);
});
// Usage in widget:
final product = ref.watch(productProvider('abc-123'));
2.8 Riverpod with Code Generation (riverpod_generator)
// --- product_providers.dart ---
part 'product_providers.g.dart';
@riverpod
Future<List<Product>> products(ProductsRef ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getProducts();
}
@riverpod
Future<Product> product(ProductRef ref, String id) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getProduct(id);
}
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => [];
void addItem(Product product, int quantity) {
state = [...state, CartItem(product: product, quantity: quantity)];
}
void removeItem(String productId) {
state = state.where((item) => item.product.id != productId).toList();
}
double get total => state.fold(0, (sum, item) => sum + item.total);
}
2.9 Derived & Computed Providers
Provider in Riverpod is not just for DI (providing repositories). It is also a computed/derived state mechanism — it watches other providers and transforms their values. This is a first-class pattern for business logic that reacts to state changes without imperatively calling ref.invalidate().
Use Case: Schema-Based Feature Visibility
/// Exposes the schema of the currently selected store.
final storeSchemaProvider = Provider<String?>((ref) {
final stores = ref.watch(ownedStoresProvider).valueOrNull ?? [];
final selectedId = ref.watch(storeSelectionProvider);
// ... find store by selectedId, return store.schema
});
/// Dashboard feature visibility derived from store schema.
class SalesDashboardVisibility {
final bool showConsignment;
final bool showTechnician;
final bool showServiceIncome;
const SalesDashboardVisibility({...});
static const none = SalesDashboardVisibility(showConsignment: false, ...);
}
final salesDashboardVisibilityProvider =
Provider<SalesDashboardVisibility>((ref) {
final schema = ref.watch(storeSchemaProvider);
switch (schema) {
case 'SIMASKO':
return const SalesDashboardVisibility(
showConsignment: true, showTechnician: true, showServiceIncome: true,
);
case 'LAUNDRY':
return const SalesDashboardVisibility(
showConsignment: false, showTechnician: false, showServiceIncome: true,
);
default:
return SalesDashboardVisibility.none;
}
});
Benefits over imperative checks:
- Reactive — when
storeSelectionProvider changes, every widget watching salesDashboardVisibilityProvider rebuilds automatically
- Testable — test the provider in isolation with
ProviderContainer(overrides: [...])
- Composable — multiple derived providers can chain:
providerA → providerB → providerC
Use Case: Boolean Flags from Entities
final isVariantProvider = Provider<bool>((ref) {
final stores = ref.watch(ownedStoresProvider).valueOrNull ?? [];
final selectedId = ref.watch(storeSelectionProvider);
// ... find store and return store.store.isVariant
});
final hasBusinessAnalysisProvider = Provider<bool>((ref) {
// ... derived from ownedStoresProvider + storeSelectionProvider
});
Use Case: Provider-Based Auto-Redirect
Use ref.listen in a widget to react to derived provider changes and navigate:
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(isVariantProvider, (_, isVariant) {
final location = GoRouterState.of(context).matchedLocation;
if (isVariant && !location.startsWith('/products/variant')) {
context.go('/products/variant');
} else if (!isVariant && location == '/products/variant') {
context.go('/products');
}
});
// ... rest of widget
}
2.10 ProviderObserver (Logging & Debugging)
class LoggerObserver extends ProviderObserver {
@override
void didUpdateProvider(
ProviderBase provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
debugPrint('[${provider.name ?? provider.runtimeType}] $previousValue → $newValue');
}
}
// In main.dart:
ProviderScope(
observers: [LoggerObserver()],
child: const MyApp(),
);
3. GoRouter
3.1 Declarative Routing Setup
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: '/products',
debugLogDiagnostics: true,
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/products',
name: 'products',
builder: (context, state) => const ProductListScreen(),
routes: [
GoRoute(
path: ':productId',
name: 'product-detail',
builder: (context, state) => ProductDetailScreen(
productId: state.pathParameters['productId']!,
),
),
],
),
GoRoute(
path: '/cart',
name: 'cart',
builder: (context, state) => const CartScreen(),
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
);
});
3.2 Deep Links
GoRouter handles deep links automatically on Android (AndroidManifest intent filters) and iOS (Associated Domains).
GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/product/:productId',
builder: (context, state) => ProductDetailScreen(
productId: state.pathParameters['productId']!,
),
),
GoRoute(
path: '/profile/:userId',
builder: (context, state) => ProfileScreen(
userId: state.pathParameters['userId']!,
),
),
],
);
Android manifest (AndroidManifest.xml):
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com"
android:pathPrefix="/product" />
</intent-filter>
iOS (Info.plist):
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>https</string>
</array>
</dict>
</array>
3.3 Redirect Guards
Use redirect to enforce auth or conditional navigation. The redirect callback runs on every navigation and provider change.
GoRouter(
redirect: (context, state) {
final authState = ref.read(authProvider); // or use provider subscription
final isLoggedIn = authState is AuthAuthenticated;
final isOnLoginPage = state.matchedLocation == '/login';
// Not logged in → redirect to login (except if already on login)
if (!isLoggedIn && !isOnLoginPage) return '/login';
// Already logged in and on login page → redirect to home
if (isLoggedIn && isOnLoginPage) return '/products';
// No redirect
return null;
},
routes: [
GoRoute(path: '/login', ...),
GoRoute(path: '/products', ...),
GoRoute(path: '/profile', ...),
],
);
Reactive redirect with ref.watch:
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
redirect: (context, state) {
final isLoggedIn = authState is AsyncData<AuthAuthenticated>;
// ...same pattern as above
},
...
);
});
3.4 ShellRoute for Nested Navigation (Bottom Tab Persistence)
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child, // GoRouter renders the matched child here
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateSelectedIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Products'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
int _calculateSelectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/cart')) return 1;
if (location.startsWith('/profile')) return 2;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/products');
case 1: context.go('/cart');
case 2: context.go('/profile');
}
}
}
3.5 StatefulShellRoute (Preserve Scroll Position per Tab)
final router = GoRouter(
initialLocation: '/products',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/products',
builder: (context, state) => const ProductListScreen(),
routes: [
GoRoute(
path: ':productId',
builder: (context, state) => ProductDetailScreen(
productId: state.pathParameters['productId']!,
),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/cart',
builder: (context, state) => const CartScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
),
],
);
// AppShell consumes navigationShell
class AppShell extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const AppShell({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Products'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
3.6 Auth Bridge: Riverpod + GoRouter (Production Pattern)
Problem
GoRouter's refreshListenable expects a Listenable, but Riverpod's reactive state is provider-based. A bridge is needed so auth state changes trigger GoRouter redirect re-evaluation. Additionally, a static callback is needed to bridge the Dio AuthInterceptor (global/stateless) to Riverpod (scoped state).
Solution: Bridge + Static Callback
class AuthRouterBridge extends ChangeNotifier {
AuthRouterBridge(Ref ref) {
ref.listen<AuthState>(authProvider, (_, _) {
// Defer notifyListeners to the next microtask to avoid racing with
// other ref.listen callbacks (e.g. login screen) that may navigate
// during the same Riverpod notification cycle.
Future.microtask(() {
notifyListeners();
});
});
}
}
final routerProvider = Provider<GoRouter>((ref) {
final bridge = AuthRouterBridge(ref);
ref.onDispose(bridge.dispose);
// Wire the static interceptor so a 401 from any Dio call triggers logout.
// Uses setUnauthenticated() NOT logout() — logout() makes an API call
// that would also 401, creating an infinite loop.
AuthInterceptor.onUnauthorized = () {
try {
ref.read(authProvider.notifier).setUnauthenticated();
} catch (_) {
// Ignore — called after provider disposal
}
};
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/splash',
refreshListenable: bridge,
redirect: _buildRedirect(ref),
routes: [
GoRoute(
path: '/splash',
pageBuilder: (_, _) =>
PageTransition.fadeScalePage(child: const SplashScreen()),
),
GoRoute(
path: '/login',
pageBuilder: (_, _) =>
PageTransition.fadeScalePage(child: const LoginScreen()),
),
// ... authenticated routes
],
);
});
Multi-State Redirect with Splash Screen
The redirect closure handles 3 auth states, not just logged-in/logged-out:
GoRouterRedirect _buildRedirect(Ref ref) =>
(BuildContext context, GoRouterState state) {
final authState = ref.read(authProvider);
final isLoggedIn = authState is AuthAuthenticated;
final location = state.matchedLocation;
final isPublicRoute = location == '/splash' || location == '/login';
// Stay on splash while auth check is running
if (authState is AuthChecking && location == '/splash') return null;
// No session — redirect from splash to login
if (authState is AuthInitial && location == '/splash') return '/login';
// Not logged in and not on a public page — redirect to login
if (!isLoggedIn && !isPublicRoute) return '/login';
// Already logged in and on a public page — go to dashboard
if (isLoggedIn && isPublicRoute) return '/dashboard/sales';
return null;
};
Safe setUnauthenticated (No API Call)
When the interceptor catches a 401, cookies are already cleared. Calling logout() would make POST /auth/logout which also returns 401, creating a loop:
/// Set unauthenticated state immediately without making an API call.
void setUnauthenticated() {
ref.read(storeSelectionProvider.notifier).clear();
clearBusinessAnalysisCache();
state = const AuthInitial();
}
3.7 Navigation Helpers
// Push a named route
context.goNamed('product-detail', pathParameters: {'productId': productId});
// Push with extra data (not serialized in URL — use sparingly)
context.push('/product/${product.id}', extra: product);
// Pop back
context.pop();
// Replace current route
context.go('/products');
// Go back or to fallback
context.popUntilRoot();
3.7 Query Parameters
GoRoute(
path: '/products',
builder: (context, state) {
final queryParams = state.uri.queryParameters;
final category = queryParams['category'];
final sort = queryParams['sort'] ?? 'newest';
return ProductListScreen(category: category, sort: sort);
},
);
// Navigate with query params
context.goNamed('products', queryParameters: {
'category': 'electronics',
'sort': 'price_asc',
});
4. Dio
4.1 Dio Client Setup
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: ApiConstants.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
dio.interceptors.addAll([
AuthInterceptor(ref),
LogInterceptor(requestBody: true, responseBody: true),
RetryInterceptor(dio: dio),
]);
return dio;
});
4.2 Interceptors
Auth Interceptor (attach token):
class AuthInterceptor extends Interceptor {
final Ref _ref;
AuthInterceptor(this._ref);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _ref.read(secureStorageProvider).getToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Attempt token refresh
try {
final newToken = await _ref.read(authRepositoryProvider).refreshToken();
await _ref.read(secureStorageProvider).saveToken(newToken);
// Retry original request with new token
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio(BaseOptions()).fetch(err.requestOptions);
handler.resolve(response);
return;
} catch (_) {
// Refresh failed — force logout
_ref.read(authProvider.notifier).logout();
_ref.read(routerProvider).go('/login');
}
}
handler.next(err);
}
}
Log Interceptor:
class LogInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestHandlerInterceptor handler) {
debugPrint('[DIO] ${options.method} ${options.path}');
debugPrint('[DIO] Headers: ${options.headers}');
debugPrint('[DIO] Body: ${options.data}');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
debugPrint('[DIO] ${response.statusCode} ${response.requestOptions.path}');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
debugPrint('[DIO] ERROR ${err.type} ${err.message}');
handler.next(err);
}
}
4.3 Error Handling (Structured)
class NetworkExceptions implements Exception {
final String message;
final int? statusCode;
final DioException? originalError;
const NetworkExceptions({
required this.message,
this.statusCode,
this.originalError,
});
factory NetworkExceptions.fromDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkExceptions(message: 'Connection timed out. Please try again.');
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final body = e.response?.data;
final serverMessage = body is Map ? body['message']?.toString() : null;
return NetworkExceptions(
message: serverMessage ?? _statusCodeMessage(statusCode),
statusCode: statusCode,
originalError: e,
);
case DioExceptionType.cancel:
return NetworkExceptions(message: 'Request was cancelled.');
case DioExceptionType.connectionError:
return NetworkExceptions(message: 'No internet connection.');
default:
return NetworkExceptions(message: 'Something went wrong.');
}
}
static String _statusCodeMessage(int? code) {
return switch (code) {
400 => 'Bad request.',
401 => 'Unauthorized. Please log in again.',
403 => 'Access denied.',
404 => 'Resource not found.',
422 => 'Validation error.',
500 => 'Server error. Please try again later.',
_ => 'Unexpected server error ($code).',
};
}
}
// Usage in repository
Future<Either<Failure, List<Product>>> getProducts() async {
try {
final response = await dio.get('/products');
final products = (response.data as List).map((json) => Product.fromJson(json)).toList();
return Right(products);
} on DioException catch (e) {
return Left(NetworkFailure(NetworkExceptions.fromDioException(e)));
}
}
4.4 Retry with dio_smart_retry
import 'package:dio_smart_retry/dio_smart_retry.dart';
dio.interceptors.add(
RetryInterceptor(
dio: dio,
logPrint: debugPrint,
retries: 3,
retryDelays: const [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 3),
],
retryableExtraStatuses: {408, 429, 500, 502, 503},
),
);
Manual retry logic (without package):
Future<Either<Failure, T>> _retryRequest<T>(Future<T> Function() request, {int maxRetries = 3}) async {
for (int attempt = 0; attempt < maxRetries; attempt++) {
try {
return Right(await request());
} on DioException catch (e) {
if (attempt == maxRetries - 1 || e.type == DioExceptionType.badResponse && e.response?.statusCode != null && ![408, 429, 500, 502, 503].contains(e.response!.statusCode)) {
return Left(NetworkFailure(NetworkExceptions.fromDioException(e)));
}
await Future.delayed(Duration(seconds: (attempt + 1) * 2));
}
}
return Left(NetworkFailure(const NetworkExceptions(message: 'Max retries exceeded.')));
}
4.5 Request Cancellation
class ApiService {
final Dio _dio;
final Map<String, CancelToken> _cancelTokens = {};
ApiService(this._dio);
Future<Response> get(String path, {String? tag, CancelToken? cancelToken}) async {
final token = cancelToken ?? CancelToken();
if (tag != null) _cancelTokens[tag] = token as CancelToken;
return _dio.get(path, cancelToken: token);
}
void cancelRequest(String tag) {
_cancelTokens[tag]?.cancel();
_cancelTokens.remove(tag);
}
void cancelAllRequests() {
for (final token in _cancelTokens.values) {
token.cancel();
}
_cancelTokens.clear();
}
}
// Usage in a Cubit
class SearchCubit extends Cubit<SearchState> {
final ApiService _api;
SearchCubit(this._api) : super(const SearchInitial());
void search(String query) {
_api.cancelRequest('search'); // Cancel previous request
emit(const SearchLoading());
_api.get('/search', queryParameters: {'q': query}, tag: 'search')
.then(...);
}
}
4.6 Dio Adapter for Testing
// test/mocks/mock_dio_adapter.dart
void setUpMockDioAdapter(Dio dio, {Map<String, Response>? responses}) {
dio.httpClientAdapter = MockAdapter(requestCallback: (request) {
final key = '${request.method}:${request.path}';
if (responses?.containsKey(key) == true) {
return responses![key];
}
return Response(requestOptions: request, statusCode: 404);
});
}
5. Clean Architecture
5.1 Layered Structure
lib/
├── core/
│ ├── constants/ # API URLs, app config, enums
│ ├── error/ # Failure class, exceptions, error handling
│ ├── network/ # Dio client, interceptors, API service
│ ├── theme/ # Material 3 theme, colors, typography
│ └── utils/ # Extensions, helpers, typedefs
├── data/
│ ├── datasources/ # Remote (API) and Local (DB) data sources
│ ├── models/ # Data models with fromJson/toJson
│ └── repositories/ # Repository implementations
├── domain/
│ ├── entities/ # Pure domain objects (no framework deps)
│ ├── repositories/ # Abstract repository interfaces
│ └── usecases/ # Business logic use cases
├── presentation/
│ ├── providers/ # Blocs / StateNotifiers / Notifiers
│ ├── screens/ # Full-screen widgets
│ └── widgets/ # Reusable UI components
├── di/ # Dependency injection (GetIt / Riverpod)
├── main.dart # App entry point
└── app.dart # App widget with ProviderScope / MultiBlocProvider + GoRouter
5.2 Domain Layer (Pure Dart — No Framework Dependencies)
Entity:
// domain/entities/product.dart
class Product {
final String id;
final String name;
final double price;
final String? imageUrl;
const Product({
required this.id,
required this.name,
required this.price,
this.imageUrl,
});
Product copyWith({...}) => Product(
id: id,
name: name ?? this.name,
price: price ?? this.price,
imageUrl: imageUrl ?? this.imageUrl,
);
}
Repository Interface:
// domain/repositories/product_repository.dart
abstract class ProductRepository {
Future<Either<Failure, List<Product>>> getProducts({String? category, String? sort});
Future<Either<Failure, Product>> getProduct(String id);
Future<Either<Failure, Unit>> addToCart(Product product, int quantity);
}
Use Case:
// domain/usecases/get_products.dart
class GetProductsUseCase {
final ProductRepository _repository;
GetProductsUseCase(this._repository);
Future<Either<Failure, List<Product>>> call({String? category, String? sort}) {
return _repository.getProducts(category: category, sort: sort);
}
}
Failure class (either pattern):
// core/error/failure.dart
sealed class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure({required String message, this.statusCode}) : super(message);
}
class CacheFailure extends Failure {
const CacheFailure({super.message = 'Cached data not found.'});
}
class NetworkFailure extends Failure {
const NetworkFailure({required String message}) : super(message);
}
5.3 Data Layer
Model (extends Entity):
// data/models/product_model.dart
class ProductModel extends Product {
const ProductModel({
required super.id,
required super.name,
required super.price,
super.imageUrl,
});
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['image_url'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price': price,
'image_url': imageUrl,
};
}
Data Source:
// data/datasources/product_remote_datasource.dart
abstract class ProductRemoteDataSource {
Future<List<ProductModel>> getProducts({String? category, String? sort});
Future<ProductModel> getProduct(String id);
}
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final Dio _dio;
ProductRemoteDataSourceImpl(this._dio);
@override
Future<List<ProductModel>> getProducts({String? category, String? sort}) async {
final response = await _dio.get('/products', queryParameters: {
if (category != null) 'category': category,
if (sort != null) 'sort': sort,
});
return (response.data as List).map((json) => ProductModel.fromJson(json)).toList();
}
@override
Future<ProductModel> getProduct(String id) async {
final response = await _dio.get('/products/$id');
return ProductModel.fromJson(response.data);
}
}
Repository Implementation:
// data/repositories/product_repository_impl.dart
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource _remoteDataSource;
final ProductLocalDataSource _localDataSource;
final NetworkInfo _networkInfo;
ProductRepositoryImpl({
required ProductRemoteDataSource remoteDataSource,
required ProductLocalDataSource localDataSource,
required NetworkInfo networkInfo,
});
@override
Future<Either<Failure, List<Product>>> getProducts({String? category, String? sort}) async {
if (await _networkInfo.isConnected) {
try {
final models = await _remoteDataSource.getProducts(category: category, sort: sort);
await _localDataSource.cacheProducts(models); // Save for offline
return Right(models);
} on DioException catch (e) {
return Left(ServerFailure(
message: NetworkExceptions.fromDioException(e).message,
statusCode: e.response?.statusCode,
));
}
} else {
try {
final cached = await _localDataSource.getCachedProducts();
return Right(cached);
} on CacheException {
return Left(const CacheFailure());
}
}
}
}
5.4 Presentation Layer
Provider/Bloc wiring:
// presentation/providers/product_providers.dart (Riverpod)
final productRepositoryProvider = Provider<ProductRepository>((ref) {
return ProductRepositoryImpl(
remoteDataSource: ref.watch(productRemoteDataSourceProvider),
localDataSource: ref.watch(productLocalDataSourceProvider),
networkInfo: ref.watch(networkInfoProvider),
);
});
final getProductsUseCaseProvider = Provider<GetProductsUseCase>((ref) {
return GetProductsUseCase(ref.watch(productRepositoryProvider));
});
final productListProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
final useCase = ref.watch(getProductsUseCaseProvider);
final result = await useCase();
return result.fold(
(failure) => throw Exception(failure.message),
(products) => products,
);
});
5.5 Dependency Injection with GetIt (Alternative to Riverpod DI)
// di/injection_container.dart
final sl = GetIt.instance;
Future<void> initDependencies() async {
// Core
sl.registerLazySingleton<Dio>(() => createDioClient());
sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl(sl()));
// Data sources
sl.registerLazySingleton<ProductRemoteDataSource>(
() => ProductRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<ProductLocalDataSource>(
() => ProductLocalDataSourceImpl(sl()),
);
// Repository
sl.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
networkInfo: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => GetProductsUseCase(sl()));
// Blocs
sl.registerFactory(() => ProductBloc(sl()));
}
6. Testing
6.1 Bloc Testing (bloc_test)
pubspec.yaml:
dev_dependencies:
bloc_test: ^9.1.7
mocktail: ^1.0.4
flutter_test:
sdk: flutter
Test a Cubit:
// test/unit/presentation/bloc/counter_cubit_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CounterCubit', () {
late CounterCubit cubit;
setUp(() {
cubit = CounterCubit();
});
tearDown(() {
cubit.close();
});
test('initial state is 0', () {
expect(cubit.state, equals(0));
});
blocTest<CounterCubit, int>(
'emits [1] when increment() is called',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [1],
);
blocTest<CounterCubit, int>(
'emits [1, 2, 2, 1, 0] for increment, increment, no-op, decrement, reset',
build: () => CounterCubit(),
act: (cubit) {
cubit.increment();
cubit.increment();
cubit.increment(); // Already 3rd call to verify state
cubit.decrement();
cubit.reset();
},
expect: () => [1, 2, 3, 2, 0],
);
});
}
Test a Bloc (with mocked use case):
class MockGetProductsUseCase extends Mock implements GetProductsUseCase {}
void main() {
late MockGetProductsUseCase mockUseCase;
late ProductBloc bloc;
setUp(() {
mockUseCase = MockGetProductsUseCase();
bloc = ProductBloc(mockUseCase);
});
tearDown(() {
bloc.close();
});
group('ProductBloc', () {
blocTest<ProductBloc, ProductState>(
'emits [ProductLoading, ProductLoaded] when products are fetched successfully',
build: () {
when(() => mockUseCase()).thenAnswer(
(_) async => Right([Product(id: '1', name: 'Test', price: 9.99)]),
);
return bloc;
},
act: (bloc) => bloc.add(const LoadProducts()),
expect: () => [
const ProductLoading(),
isA<ProductLoaded>().having((s) => s.products.first.name, 'name', 'Test'),
],
);
blocTest<ProductBloc, ProductState>(
'emits [ProductLoading, ProductError] when fetch fails',
build: () {
when(() => mockUseCase()).thenAnswer(
(_) async => Left(ServerFailure(message: 'Server error')),
);
return bloc;
},
act: (bloc) => bloc.add(const LoadProducts()),
expect: () => [
const ProductLoading(),
isA<ProductError>().having((s) => s.message, 'message', 'Server error'),
],
);
});
}
6.2 Riverpod Testing
pubspec.yaml:
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
Test a NotifierProvider:
// test/unit/presentation/providers/counter_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CounterNotifier', () {
late ProviderContainer container;
setUp(() {
container = ProviderContainer();
});
tearDown(() {
container.dispose();
});
test('initial value is 0', () {
expect(container.read(counterProvider), equals(0));
});
test('increment increases count by 1', () {
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), equals(1));
});
test('decrement decreases count by 1', () {
container.read(counterProvider.notifier).increment();
container.read(counterProvider.notifier).increment();
container.read(counterProvider.notifier).decrement();
expect(container.read(counterProvider), equals(1));
});
});
}
Test a FutureProvider with mocked repository:
// test/unit/presentation/providers/product_list_provider_test.dart
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
test('productListProvider returns products when repository succeeds', () async {
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Right([Product(id: '1', name: 'Test', price: 9.99)]),
);
final container = ProviderContainer(overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
]);
final productsAsync = container.read(productListProvider);
expect(productsAsync, const AsyncValue<List<Product>>.loading());
// Wait for async resolution
await container.read(productListProvider.future);
final result = container.read(productListProvider);
expect(result.hasValue, isTrue);
expect(result.value!.length, equals(1));
expect(result.value!.first.name, equals('Test'));
container.dispose();
});
test('productListProvider returns error when repository fails', () async {
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Left(ServerFailure(message: 'API error')),
);
final container = ProviderContainer(overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
]);
await expectLater(
() => container.read(productListProvider.future),
throwsA(isA<Exception>()),
);
container.dispose();
});
}
6.3 Widget Testing (with ProviderScope)
// test/widget/screens/product_list_screen_test.dart
Widget createTestWidget({ProviderContainer? container, Override? override}) {
return ProviderScope(
overrides: override != null ? [override] : [],
child: MaterialApp.router(
routerConfig: createTestRouter(),
title: 'Test App',
),
);
}
void main() {
group('ProductListScreen', () {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
testWidgets('shows loading indicator initially', (tester) async {
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Right<ProductFailure, List<Product>>([]),
);
await tester.pumpWidget(createTestWidget(
override: productRepositoryProvider.overrideWithValue(mockRepository),
));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows product list when data is loaded', (tester) async {
final products = [
Product(id: '1', name: 'Widget A', price: 19.99),
Product(id: '2', name: 'Gadget B', price: 29.99),
];
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Right<ProductFailure, List<Product>>(products),
);
await tester.pumpWidget(createTestWidget(
override: productRepositoryProvider.overrideWithValue(mockRepository),
));
// Wait for async
await tester.pumpAndSettle();
expect(find.text('Widget A'), findsOneWidget);
expect(find.text('Gadget B'), findsOneWidget);
});
testWidgets('shows error message on failure', (tester) async {
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Left(ServerFailure(message: 'API error')),
);
await tester.pumpWidget(createTestWidget(
override: productRepositoryProvider.overrideWithValue(mockRepository),
));
await tester.pumpAndSettle();
expect(find.textContaining('API error'), findsOneWidget);
});
});
}
6.4 Mocktail Patterns
// Mocking classes
class MockProductRepository extends Mock implements ProductRepository {}
class MockDio extends Mock implements Dio {}
class MockSharedPreferences extends Mock implements SharedPreferences {}
// Mocking async methods
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Right([Product(id: '1', name: 'Test', price: 9.99)]),
);
// Mocking streams
when(() => mockRepository.observeProducts()).thenAnswer(
(_) => Stream.value([Product(id: '1', name: 'Live', price: 9.99)]),
);
// Mocking void methods
when(() => mockRepository.deleteProduct('1')).thenAnswer((_) async => const Right(unit));
// Verify interactions
verify(() => mockRepository.getProducts()).called(1);
verify(() => mockRepository.getProduct('1')).called(1);
verifyNoMoreInteractions(mockRepository);
// Capture arguments
final captured = verify(() => mockRepository.addToCart(captureAny(), captureAny())).captured;
expect((captured[0] as Product).name, 'Test');
expect(captured[1], 2);
6.5 Unit Testing Use Cases and Repositories
// test/unit/domain/usecases/get_products_test.dart
void main() {
late GetProductsUseCase useCase;
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
useCase = GetProductsUseCase(mockRepository);
});
test('should return products from repository', () async {
final products = [Product(id: '1', name: 'Test', price: 9.99)];
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Right(products),
);
final result = await useCase();
expect(result, Right(products));
verify(() => mockRepository.getProducts()).called(1);
});
test('should return failure when repository fails', () async {
when(() => mockRepository.getProducts()).thenAnswer(
(_) async => Left(ServerFailure(message: 'Server error')),
);
final result = await useCase();
expect(result, Left(ServerFailure(message: 'Server error')));
});
}
// test/unit/data/repositories/product_repository_impl_test.dart
void main() {
late ProductRepositoryImpl repository;
late MockProductRemoteDataSource mockRemote;
late MockProductLocalDataSource mockLocal;
late MockNetworkInfo mockNetworkInfo;
setUp(() {
mockRemote = MockProductRemoteDataSource();
mockLocal = MockProductLocalDataSource();
mockNetworkInfo = MockNetworkInfo();
repository = ProductRepositoryImpl(
remoteDataSource: mockRemote,
localDataSource: mockLocal,
networkInfo: mockNetworkInfo,
);
});
group('getProducts', () {
const tCategory = 'electronics';
final tModels = [ProductModel(id: '1', name: 'Test', price: 9.99)];
test('should return remote data when online', () async {
when(() => mockNetworkInfo.isConnected).thenAnswer((_) async => true);
when(() => mockRemote.getProducts(category: tCategory)).thenAnswer((_) async => tModels);
when(() => mockLocal.cacheProducts(tModels)).thenAnswer((_) async => {});
final result = await repository.getProducts(category: tCategory);
expect(result, Right(tModels));
verify(() => mockRemote.getProducts(category: tCategory)).called(1);
verify(() => mockLocal.cacheProducts(tModels)).called(1);
verifyNoMoreInteractions(mockRemote);
verifyNoMoreInteractions(mockLocal);
});
test('should return cached data when offline', () async {
when(() => mockNetworkInfo.isConnected).thenAnswer((_) async => false);
when(() => mockLocal.getCachedProducts()).thenAnswer((_) async => tModels);
final result = await repository.getProducts(category: tCategory);
expect(result, Right(tModels));
verifyZeroInteractions(mockRemote);
verify(() => mockLocal.getCachedProducts()).called(1);
});
});
}
6.6 Test Organization
test/
├── unit/
│ ├── core/ # Error, constants, utils tests
│ ├── domain/
│ │ ├── entities/ # Entity value equality, copyWith
│ │ └── usecases/ # Use case logic with mocks
│ ├── data/
│ │ ├── datasources/ # Remote/Local data source tests
│ │ ├── models/ # fromJson/toJson round-trip
│ │ └── repositories/ # Repository implementation with mocks
│ └── presentation/
│ ├── blocs/ # bloc_test for each Bloc/Cubit
│ └── providers/ # ProviderContainer tests for Riverpod
├── widget/
│ ├── screens/ # Full-screen widget tests with ProviderScope
│ └── widgets/ # Reusable widget tests
└── integration/ # Full flow integration tests
6.7 Testing Commands
flutter test
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
flutter test test/unit/presentation/bloc/
flutter test test/unit/domain/usecases/get_products_test.dart
flutter test --name "emits [ProductLoading, ProductLoaded]"
flutter test --update-goldens
flutter test --watch
Quick Reference: Bloc vs Riverpod Decision Guide
| Scenario | Recommended | Reason |
|---|
| Deeply nested state with complex events | Bloc | Event log, transformers, traceability |
| Simple form or toggle state | Cubit or Riverpod Notifier | Less boilerplate |
| Async data fetching (one-shot) | Riverpod FutureProvider | autoDispose, AsyncValue.when |
| Real-time streams (WebSocket, Firestore) | Riverpod StreamProvider | .when on connection state |
| Auth flow with token refresh | Bloc | Events track login/logout/refresh steps |
| Cross-cutting dependencies (repos, config) | Riverpod Provider | No BuildContext needed |
| Multi-tab app preserving scroll position | GoRouter StatefulShellRoute | Built-in indexed stack |
| Team prefers strict structure | Bloc + Clean Architecture | Enforced separation of concerns |
| Rapid prototyping / small apps | Riverpod | Less files, less boilerplate |