opencode-agent-kit
Version:
Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)
1,813 lines (1,496 loc) • 50.3 kB
Markdown
---
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
1. [Bloc Pattern](#1-bloc-pattern)
2. [Riverpod](#2-riverpod)
3. [GoRouter](#3-gorouter)
4. [Dio](#4-dio)
5. [Clean Architecture](#5-clean-architecture)
6. [Testing](#6-testing)
---
## 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
```dart
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
```dart
// --- 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.
```dart
// 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.
```dart
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.
```dart
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
```dart
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.
```dart
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).
```dart
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)
```dart
// --- 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)
```dart
// --- 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
```dart
final productListProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
final repository = ref.watch(productRepositoryProvider);
return repository.getProducts();
});
```
**Usage with AsyncValue:**
```dart
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
```dart
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) |
```dart
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:**
```dart
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:**
```dart
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)
```dart
// --- 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 ProviderObserver (Logging & Debugging)
```dart
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
```dart
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).
```dart
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`):**
```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`):**
```xml
<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.
```dart
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:**
```dart
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)
```dart
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)
```dart
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 Navigation Helpers
```dart
// 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
```dart
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
```dart
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):**
```dart
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:**
```dart
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)
```dart
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
```dart
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):**
```dart
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
```dart
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
```dart
// 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:**
```dart
// 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:**
```dart
// 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:**
```dart
// 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):**
```dart
// 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):**
```dart
// 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:**
```dart
// 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:**
```dart
// 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:**
```dart
// 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)
```dart
// 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:**
```yaml
dev_dependencies:
bloc_test: ^9.1.7
mocktail: ^1.0.4
flutter_test:
sdk: flutter
```
**Test a Cubit:**
```dart
// 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):**
```dart
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:**
```yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
```
**Test a NotifierProvider:**
```dart
// 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:**
```dart
// 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)
```dart
// 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
```dart
// 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
```dart
// 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')));
});
}
```
```dart
// 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
```bash
# Run all tests
flutter test
# Run with coverage
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
# Run specific directory
flutter test test/unit/presentation/bloc/
# Run single file
flutter test test/unit/domain/usecases/get_products_test.dart
# Run with name filter
flutter test --name "emits [ProductLoading, ProductLoaded]"
# Update golden files
flutter test --update-goldens
# Watch mode (auto-rerun on change)
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 |