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,069 lines (869 loc) • 32.5 kB
Markdown
---
name: flutter-dio-multi-service
description: >-
Multi-microservice Dio networking patterns for Flutter: cookie-based session auth with cookie_jar,
shared PersistCookieJar across multiple Dio instances, Bearer token fallback, AuthInterceptor with
401 detection on both onResponse and onError, static cross-cutting logout callback, ApiService thin
typed wrapper with response envelope validation, dual-format envelope parser (status/success),
cursor-based pagination, and dual-layer error hierarchy (Exception + Failure). Designed for production
Flutter apps connecting to multiple backend microservices.
license: MIT
metadata:
author: opencode-agent-kit
version: '1.0.0'
target_agent: flutter-developer
stack:
- Dart 3
- Flutter SDK
- Dio
- cookie_jar
- dio_cookie_manager
- flutter_secure_storage
- dartz
- path_provider
---
# Flutter: Multi-Service Dio & Cookie Auth
**Target Agent:** -developer
**Stack:** Dart 3 · Dio · cookie_jar · dio_cookie_manager · flutter_secure_storage · dartz · path_provider
Production patterns for connecting Flutter apps to multiple backend microservices with cookie-based authentication, typed error handling, response envelope parsing, and cursor pagination.
---
## Table of Contents
1. [Multi-Service Dio Setup](#1-multi-service-dio-setup)
2. [Cookie-Based Auth Interceptor](#2-cookie-based-auth-interceptor)
3. [Thin Typed ApiService](#3-thin-typed-apiservice)
4. [Dual-Format Response Envelope](#4-dual-format-response-envelope)
5. [Cursor Pagination Model](#5-cursor-pagination-model)
6. [Dual-Layer Error Hierarchy](#6-dual-layer-error-hierarchy)
7. [Service-Specific Environment Config](#7-service-specific-environment-config)
8. [Auth Bridge: Riverpod + GoRouter](#8-auth-bridge-riverpod--gorouter)
9. [Testing Multi-Service Dio](#9-testing-multi-service-dio)
10. [Complete Data Flow: Auth Example](#10-complete-data-flow-auth-example)
---
## 1. Multi-Service Dio Setup
### Problem
Modern Flutter apps often talk to **multiple backend microservices** — auth, HR, reporting, config, product — each with its own base URL but sharing the same auth context (cookies).
### Solution
Create a `DioClient` singleton that initializes one `Dio` instance per service, all sharing a single `PersistCookieJar`. This keeps cookies in sync across all services.
```dart
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:path_provider/path_provider.dart';
class DioClient {
DioClient._();
static PersistCookieJar? _cookieJar;
static Dio? _dashboardDio;
static Dio? _hrDio;
static Dio? _reportDio;
static Dio? _configDio;
static Dio? _productDio;
/// Must be called once before accessing any Dio instance.
/// In main.dart: await DioClient.init();
static Future<void> init() async {
final dir = await getApplicationDocumentsDirectory();
final cookiePath = '${dir.path}/app_cookies';
_cookieJar = PersistCookieJar(
ignoreExpires: true, // let the server decide expiry via 401
storage: FileStorage(cookiePath),
);
_dashboardDio = _createDio(
baseUrl: ApiConstants.coreBaseUrl,
cookieJar: _cookieJar!,
);
_hrDio = _createDio(
baseUrl: ApiConstants.hrBaseUrl,
cookieJar: _cookieJar!,
);
_reportDio = _createDio(
baseUrl: ApiConstants.reportBaseUrl,
cookieJar: _cookieJar!,
);
_configDio = _createDio(
baseUrl: ApiConstants.configBaseUrl,
cookieJar: _cookieJar!,
);
_productDio = _createDio(
baseUrl: ApiConstants.productBaseUrl,
cookieJar: _cookieJar!,
);
}
static PersistCookieJar get cookieJar {
assert(_cookieJar != null, 'DioClient.init() must be called first');
return _cookieJar!;
}
static Dio get dashboardDio {
assert(_dashboardDio != null, 'DioClient.init() must be called first');
return _dashboardDio!;
}
static Dio get hrDio {
assert(_hrDio != null, 'DioClient.init() must be called first');
return _hrDio!;
}
static Dio get reportDio {
assert(_reportDio != null, 'DioClient.init() must be called first');
return _reportDio!;
}
static Dio get configDio {
assert(_configDio != null, 'DioClient.init() must be called first');
return _configDio!;
}
static Dio get productDio {
assert(_productDio != null, 'DioClient.init() must be called first');
return _productDio!;
}
/// Reset all instances (e.g. on logout to clear cookie state).
static void reset() {
_dashboardDio = null;
_hrDio = null;
_reportDio = null;
_configDio = null;
_productDio = null;
_cookieJar?.deleteAll();
_cookieJar = null;
}
static Dio _createDio({
required String baseUrl,
required PersistCookieJar cookieJar,
}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
// Don't throw on error status codes — handle them in repository layer
validateStatus: (_) => true,
),
);
dio.interceptors.addAll([
CookieManager(cookieJar),
AuthInterceptor(cookieJar),
LogInterceptor(requestBody: true, responseBody: true),
]);
return dio;
}
}
```
### Key Design Decisions
| Decision | Rationale |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `validateStatus: (_) => true` | Prevents Dio from throwing on HTTP error codes. All error handling is centralized in the repository/datasource layer. |
| `CookieManager` before `AuthInterceptor` | Ensures cookies are loaded/stored before the auth interceptor checks them. |
| `ignoreExpires: true` | Server controls session expiry via 401 responses, not cookie expiry dates. |
| `reset()` method | Called on logout to clear all cookies from disk and force all services to re-authenticate. |
### When to Add More Services
If your app grows beyond 5 services, extract a factory pattern:
```dart
// ponytail: if services grow past 5, refactor to a registry
class DioRegistry {
final Map<String, Dio> _instances = {};
Dio get(String name) {
assert(_instances.containsKey(name), 'Dio "$name" not registered');
return _instances[name]!;
}
void register(String name, String baseUrl, PersistCookieJar cookieJar) {
_instances[name] = _createDio(baseUrl: baseUrl, cookieJar: cookieJar);
}
}
```
---
## 2. Cookie-Based Auth Interceptor
### Problem
Bearer tokens are the default auth pattern but many enterprise backends use **cookie-based sessions**. Cookies are set by the login response and sent automatically on subsequent requests.
### Cookie Interceptor
```dart
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._cookieJar);
final PersistCookieJar _cookieJar;
/// Optional callback — wire to auth provider navigation from outside.
/// Uses a static callback to bridge the interceptor (global/stateless) to
/// Riverpod (scoped state) without making the interceptor depend on Riverpod.
static void Function()? onUnauthorized;
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
_ensureBearerFallback(options);
handler.next(options);
}
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Catch 401 even when validateStatus is true (which prevents onError
// from firing for HTTP status codes).
if (response.statusCode == 401) {
_onUnauthorized();
}
handler.next(response);
}
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
_onUnauthorized();
}
handler.next(err);
}
void _onUnauthorized() {
_cookieJar.deleteAll();
onUnauthorized?.call();
}
}
```
### Bearer Fallback for Cross-Origin Services
Some services (e.g., HR) may not share cookies due to cross-origin constraints. Add a Bearer token fallback:
```dart
Future<void> _ensureBearerFallback(RequestOptions options) async {
if (!options.baseUrl.contains('hr')) return;
// Check if we already have cookies for this request
final cookies = await _cookieJar.loadForRequest(options.uri);
if (cookies.isNotEmpty) return;
// Fallback: try Bearer token from secure storage
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'access_token');
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
} catch (_) {
// Secure storage not available (web) — skip Bearer fallback
}
}
```
### Static OnUnauthorized Bridge
The static `onUnauthorized` callback is wired once at app startup to trigger logout:
```dart
// In app_router.dart:
AuthInterceptor.onUnauthorized = () {
try {
ref.read(authProvider.notifier).setUnauthenticated();
} catch (_) {
// Ignore — called after provider disposal
}
};
```
### Auth Interceptor Flow
```
Request → [CookieManager loads cookies] → [AuthInterceptor checks Bearer fallback]
↘ Response ← [Dio executes request]
↘ If 401 → [AuthInterceptor._onUnauthorized]
→ [cookieJar.deleteAll()]
→ [onUnauthorized callback]
→ [authProvider.setUnauthenticated()]
→ [GoRouter redirects to /login]
```
### Warning: Avoid Infinite Loops
When the interceptor catches a 401, **do not call `logout()`** (which makes a `POST /auth/logout` call that would also 401 since cookies are already cleared). Instead call `setUnauthenticated()` — a local state-only method that does not make API calls:
```dart
void setUnauthenticated() {
ref.read(storeSelectionProvider.notifier).clear();
clearBusinessAnalysisCache();
state = const AuthInitial();
}
```
---
## 3. Thin Typed ApiService
### Problem
Raw Dio calls in every data source mean repeated boilerplate for response validation, error mapping, and type casting.
### Solution
Create a thin wrapper that centralizes error mapping and response envelope validation:
```dart
import 'package:dio/dio.dart';
class ApiService {
ApiService(this._dio);
final Dio _dio;
Future<Map<String, dynamic>> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
path,
queryParameters: queryParameters,
);
return _validate(response.data);
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<List<dynamic>> getList(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await _dio.get<List<dynamic>>(
path,
queryParameters: queryParameters,
);
return response.data ?? [];
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<Map<String, dynamic>> post(String path, {Object? data}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(path, data: data);
return _validate(response.data);
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<Map<String, dynamic>> put(String path, {Object? data}) async {
try {
final response = await _dio.put<Map<String, dynamic>>(path, data: data);
return _validate(response.data);
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<Map<String, dynamic>> delete(String path) async {
try {
final response = await _dio.delete<Map<String, dynamic>>(path);
return _validate(response.data);
} on DioException catch (e) {
throw _mapError(e);
}
}
Map<String, dynamic> _validate(Map<String, dynamic>? data) {
if (data == null) {
throw ServerException('Empty response', statusCode: 500);
}
return ApiResponseHelper.requireSuccess(data);
}
AppException _mapError(DioException e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return NetworkException('Connection timeout');
}
if (e.type == DioExceptionType.connectionError) {
return NetworkException('No internet connection');
}
final statusCode = e.response?.statusCode;
final body = e.response?.data as Map<String, dynamic>?;
final message = body?['message'] as String? ?? e.message ?? 'Unknown error';
if (statusCode == 401) {
return UnauthorizedException(message);
}
if (statusCode == 404) {
return ServerException(message, statusCode: 404);
}
if (statusCode == 422) {
return ValidationException(
message,
fieldErrors: body?['errors'] as Map<String, List<String>>?,
);
}
return ServerException(message, statusCode: statusCode ?? 500);
}
}
```
### Usage in Data Sources
```dart
class ProductDataSource {
ProductDataSource({Dio? dio}) : _api = ApiService(dio ?? DioClient.productDio);
final ApiService _api;
Future<Map<String, dynamic>> getProducts({String? cursor}) async {
return _api.get('/products', queryParameters: {
if (cursor != null) 'cursor': cursor,
});
}
Future<Map<String, dynamic>> createProduct(Map<String, dynamic> data) async {
return _api.post('/products', data: data);
}
}
```
---
## 4. Dual-Format Response Envelope
### Problem
Different backend services use different response envelope formats. The auth service returns `{ status: true, message: "...", data: {...} }` while another service uses `{ success: true, message: "...", data: {...} }`.
### Solution
A helper that checks both formats:
```dart
/// Helper to handle two response envelope formats:
/// Auth / HR: { status, message, data }
/// Others: { success, message, data }
abstract class ApiResponseHelper {
ApiResponseHelper._();
static bool isSuccess(Map<String, dynamic> json) =>
json['status'] == true || json['success'] == true;
static String getMessage(Map<String, dynamic> json) =>
json['message'] as String? ?? '';
static Map<String, dynamic>? getData(Map<String, dynamic> json) =>
json['data'] as Map<String, dynamic>?;
static List<dynamic>? getDataList(Map<String, dynamic> json) =>
json['data'] as List<dynamic>?;
/// Throws if the response is not a success.
static Map<String, dynamic> requireSuccess(Map<String, dynamic> json) {
if (!isSuccess(json)) {
throw ApiResponseException(getMessage(json));
}
return json;
}
}
class ApiResponseException implements Exception {
ApiResponseException(this.message);
final String message;
String toString() => 'ApiResponseException: $message';
}
```
### Usage in Repository Implementation
```dart
class AuthRepositoryImpl implements AuthRepository {
// ...
Future<Either<Failure, AuthSession>> login(
String username,
String password,
) async {
try {
final json = await _dataSource.login(username, password);
final data =
ApiResponseHelper.requireSuccess(json)['data']
as Map<String, dynamic>;
return Right(_parseSession(data));
} on AppException catch (e) {
return Left(_mapFailure(e));
} on ApiResponseException catch (e) {
return Left(ServerFailure(message: e.message));
} on DioException catch (e) {
return Left(_mapDioFailure(e));
}
}
}
```
---
## 5. Cursor Pagination Model
### Problem
Many APIs return paginated results using cursor-based pagination. There is no standard model in the Flutter ecosystem.
### Solution
A generic `CursorPage<T>` model:
```dart
class CursorPage<T> {
const CursorPage({
required this.items,
required this.hasMore,
this.nextCursor,
});
factory CursorPage.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromItem,
) {
final itemsJson = json['items'] as List<dynamic>? ?? [];
return CursorPage(
items: itemsJson
.map((e) => fromItem(e as Map<String, dynamic>))
.toList(growable: false),
nextCursor: json['next_cursor'] as String?,
hasMore: json['has_more'] as bool? ?? false,
);
}
final List<T> items;
final String? nextCursor;
final bool hasMore;
}
```
### Usage with Riverpod Notifier
```dart
class ProductListState {
const ProductListState({
this.items = const [],
this.nextCursor,
this.hasMore = true,
this.isLoading = false,
this.isLoadingMore = false,
this.error,
});
final List<ProductItem> items;
final String? nextCursor;
final bool hasMore;
final bool isLoading;
final bool isLoadingMore;
final String? error;
ProductListState copyWith({
List<ProductItem>? items,
String? nextCursor,
bool? hasMore,
bool? isLoading,
bool? isLoadingMore,
String? error,
}) {
return ProductListState(
items: items ?? this.items,
nextCursor: nextCursor ?? this.nextCursor,
hasMore: hasMore ?? this.hasMore,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
error: error ?? this.error,
);
}
}
class ProductListNotifier extends Notifier<ProductListState> {
ProductListState build() => const ProductListState();
Future<void> loadProducts() async {
state = state.copyWith(isLoading: true, error: null);
// ... fetch and update state
}
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore) return;
state = state.copyWith(isLoadingMore: true);
// ... fetch next page with state.nextCursor
}
}
```
---
## 6. Dual-Layer Error Hierarchy
### Problem
Clean Architecture uses `Either<Failure, T>` in the domain layer, but low-level code (Dio, file I/O) typically uses `throw`. A dual-layer model bridges the two worlds cleanly.
### Layer 1: Exceptions (for imperative/thrown errors)
```dart
class AppException implements Exception {
AppException(this.message, {this.statusCode});
final String message;
final int? statusCode;
String toString() => 'AppException: $message (status: $statusCode)';
}
class ServerException extends AppException {
ServerException(super.message, {super.statusCode});
}
class NetworkException extends AppException {
NetworkException(super.message);
}
class UnauthorizedException extends AppException {
UnauthorizedException([super.message = 'Unauthorized'])
: super(statusCode: 401);
}
class CacheException extends AppException {
CacheException(super.message);
}
class ValidationException extends AppException {
ValidationException(super.message, {this.fieldErrors})
: super(statusCode: 422);
final Map<String, List<String>>? fieldErrors;
}
```
### Layer 2: Failures (for Either/functional errors)
```dart
sealed class Failure {
const Failure({required this.message, this.statusCode});
final String message;
final int? statusCode;
String get displayMessage => message;
}
class ServerFailure extends Failure {
const ServerFailure({required super.message, super.statusCode});
}
class NetworkFailure extends Failure {
const NetworkFailure({required super.message});
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure({required super.message});
}
class NotFoundFailure extends Failure {
const NotFoundFailure({required super.message});
}
class ValidationFailure extends Failure {
const ValidationFailure({required super.message, this.fieldErrors});
final Map<String, List<String>>? fieldErrors;
}
class CacheFailure extends Failure {
const CacheFailure({required super.message});
}
class UnknownFailure extends Failure {
const UnknownFailure({super.message = 'Something went wrong'});
}
```
### Mapping Exceptions to Failures in Repository
```dart
Failure _mapFailure(AppException e) {
if (e is UnauthorizedException) {
return UnauthorizedFailure(message: e.message);
}
return ServerFailure(message: e.message, statusCode: e.statusCode);
}
Failure _mapDioFailure(DioException e) {
if (e.type == DioExceptionType.badResponse &&
e.response?.statusCode == 401) {
return const UnauthorizedFailure(message: 'Access token missing');
}
if (e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.connectionTimeout) {
return const NetworkFailure(message: 'No internet connection');
}
return ServerFailure(message: e.message ?? 'Something went wrong');
}
```
---
## 7. Service-Specific Environment Config
### Problem
Each microservice has a different base URL, and these differ between development, staging, and production.
### Solution
Use `--dart-define-from-file` for compile-time configuration:
```dart
class ApiConstants {
ApiConstants._();
static const String coreBaseUrl = String.fromEnvironment(
'CORE_BASE_URL',
defaultValue: 'https://api.example.com/core/v1',
);
static const String hrBaseUrl = String.fromEnvironment(
'HR_BASE_URL',
defaultValue: 'https://api.example.com/hr/v1',
);
static const String reportBaseUrl = String.fromEnvironment(
'REPORT_BASE_URL',
defaultValue: 'https://api.example.com/report/v1',
);
static const String configBaseUrl = String.fromEnvironment(
'CONFIG_BASE_URL',
defaultValue: 'https://api.example.com/config/v1',
);
static const String productBaseUrl = String.fromEnvironment(
'PRODUCT_BASE_URL',
defaultValue: 'https://api.example.com/product/v1',
);
static const Duration requestTimeout = Duration(seconds: 15);
static const String cookieStorageKey = 'app_cookies';
}
```
**env.properties (gitignored):**
```properties
CORE_BASE_URL=https://api.example.com/core/v1
HR_BASE_URL=https://api.example.com/hr/v1
REPORT_BASE_URL=https://api.example.com/report/v1
CONFIG_BASE_URL=https://api.example.com/config/v1
PRODUCT_BASE_URL=https://api.example.com/product/v1
```
**Run commands:**
```bash
flutter run --dart-define-from-file=env.properties
flutter build apk --dart-define-from-file=env.properties
flutter build ios --dart-define-from-file=env.properties
```
**env.properties.example (committed):**
```properties
CORE_BASE_URL=
HR_BASE_URL=
REPORT_BASE_URL=
CONFIG_BASE_URL=
PRODUCT_BASE_URL=
```
---
## 8. Auth Bridge: Riverpod + GoRouter
### 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.
### Solution
A `ChangeNotifier` that listens to Riverpod auth state and notifies GoRouter:
```dart
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.
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 with parentNavigatorKey for modal routes
],
);
});
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;
};
```
### Auth State Machine
```
┌──────────┐
│ Checking │ ← App starts here (splash screen)
└────┬─────┘
│
┌─────────┴──────────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ Initial │ │Authentic.│ ← GoRouter redirects to /dashboard
└────┬────┘ └──────────┘
│ │
▼ ▼ (401 from any API / logout)
┌─────────┐ │
│ Login │ │
│ Screen │ │
└────┬────┘ │
│ (successful login) │
└──────────────────────┘
```
---
## 9. Testing Multi-Service Dio
### Dependencies
```yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
```
### Test Pattern: Data Source
```dart
class MockDio extends Mock implements Dio {}
void main() {
late MockDio mockDio;
late AuthDataSource dataSource;
setUp(() {
mockDio = MockDio();
dataSource = AuthDataSource(dio: mockDio);
});
group('login', () {
test('returns raw json on success', () async {
when(() => mockDio.post(
any(),
data: any(named: 'data'),
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: ''),
statusCode: 200,
data: {'status': true, 'data': {'token': 'abc'}},
));
final result = await dataSource.login('admin', 'pass123');
expect(result, {'status': true, 'data': {'token': 'abc'}});
verify(() => mockDio.post(
any(),
data: {'username': 'admin', 'password': 'pass123', 'is_mobile': true},
)).called(1);
});
});
}
```
### Test Pattern: Repository
```dart
class MockAuthDataSource extends Mock implements AuthDataSource {}
void main() {
late MockAuthDataSource mockDataSource;
late AuthRepositoryImpl repository;
setUp(() {
mockDataSource = MockAuthDataSource();
repository = AuthRepositoryImpl(mockDataSource);
});
group('login', () {
test('returns AuthSession on success', () async {
when(() => mockDataSource.login(any(), any())).thenAnswer(
(_) async => {'status': true, 'data': {/* ... */}},
);
final result = await repository.login('admin', 'pass123');
expect(result.isRight(), true);
});
test('returns UnauthorizedFailure on 401', () async {
when(() => mockDataSource.login(any(), any())).thenThrow(
UnauthorizedException('Invalid credentials'),
);
final result = await repository.login('admin', 'pass123');
expect(result.isLeft(), true);
result.fold(
(failure) => expect(failure, isA<UnauthorizedFailure>()),
(_) => fail('Expected failure'),
);
});
});
}
```
---
## 10. Complete Data Flow: Auth Example
```
┌─────────────────────────────────────────────────────────────┐
│ main.dart │
│ await DioClient.init(); │
│ → Creates 5 Dio instances, each with CookieManager │
│ + AuthInterceptor, all sharing PersistCookieJar │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ SplashScreen │
│ → ref.read(authProvider.notifier).checkSession() │
│ → GET /auth/me via _dataSource.getMe() │
│ → cookie_jar auto-sends existing cookies │
│ → If 401 → AuthInterceptor._onUnauthorized → AuthInitial │
│ → If 200 → AuthRouterBridge.notifyListeners() │
│ → GoRouter redirect() → /dashboard/sales │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ LoginScreen │
│ → ref.read(authProvider.notifier).login(user, pass) │
│ → POST /auth/login via _dataSource.login() │
│ → Server sets Set-Cookie header │
│ → CookieManager saves to PersistCookieJar │
│ → ApiResponseHelper checks { status: true } envelope │
│ → Repository maps json → AuthSession entity │
│ → AuthNotifier state = AuthAuthenticated │
│ → AuthRouterBridge.notifyListeners() │
│ → GoRouter redirect() → /dashboard/sales │
└─────────────────────────────────────────────────────────────┘
```
---
## Complete pubspec Dependencies
```yaml
dependencies:
dio: ^5.7.0
cookie_jar: ^4.0.8
dio_cookie_manager: ^3.1.1
flutter_secure_storage: ^9.2.4
path_provider: ^2.1.5
dartz: ^0.10.1
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
```
---
## Migration Guide: Bearer → Cookie Auth
If your app currently uses Bearer tokens and you need to switch to cookies:
1. **Add dependencies**: `cookie_jar`, `dio_cookie_manager`
2. **Create DioClient** with `PersistCookieJar` and `CookieManager` interceptor
3. **Replace AuthInterceptor** — instead of reading/setting Bearer tokens, let `CookieManager` handle cookies automatically
4. **Keep Bearer fallback** — some cross-origin services may still need it
5. **Update login flow** — the server now sets `Set-Cookie` instead of returning a token JSON body
6. **Update logout flow** — call `DioClient.reset()` to clear all cookies from disk
7. **Add `validateStatus: (_) => true`** to all Dio instances so 401 is handled in the interceptor, not thrown as an exception