Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
133
lib/core/network/api_client.dart
Normal file
133
lib/core/network/api_client.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../config/environment.dart';
|
||||
import '../di/injection.dart';
|
||||
import '../error/error_handler.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/datasources/keycloak_auth_service.dart';
|
||||
|
||||
/// Client réseau unifié basé sur Dio (Version DRY & Minimaliste).
|
||||
@lazySingleton
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
);
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Intercepteur de Log (Uniquement en Dev)
|
||||
if (AppConfig.enableLogging) {
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
logPrint: (obj) => print('🌐 [API] $obj'),
|
||||
));
|
||||
}
|
||||
|
||||
// Intercepteur de Token & Refresh automatique
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Utilise la clé 'kc_access' synchronisée avec KeycloakAuthService
|
||||
final token = await _storage.read(key: 'kc_access');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (DioException e, handler) async {
|
||||
// Évite une boucle infinie si le retry échoue aussi avec 401
|
||||
final isRetry = e.requestOptions.extra['custom_retry'] == true;
|
||||
|
||||
if (e.response?.statusCode == 401 && !isRetry) {
|
||||
final responseBody = e.response?.data;
|
||||
debugPrint('🔑 [API] 401 Detected. Body: $responseBody. Attempting token refresh...');
|
||||
final refreshed = await _refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
final token = await _storage.read(key: 'kc_access');
|
||||
if (token != null) {
|
||||
// Marque la requête comme étant un retry
|
||||
final options = e.requestOptions;
|
||||
options.extra['custom_retry'] = true;
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [API] Retrying request: ${options.path}');
|
||||
final response = await _dio.fetch(options);
|
||||
return handler.resolve(response);
|
||||
} on DioException catch (retryError) {
|
||||
final retryBody = retryError.response?.data;
|
||||
debugPrint('🚨 [API] Retry failed with status: ${retryError.response?.statusCode}. Body: $retryBody');
|
||||
if (retryError.response?.statusCode == 401) {
|
||||
debugPrint('🚪 [API] Persistent 401. Force Logout.');
|
||||
_forceLogout();
|
||||
}
|
||||
return handler.next(retryError);
|
||||
} catch (retryError) {
|
||||
debugPrint('🚨 [API] Retry critical error: $retryError');
|
||||
return handler.next(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint('🚪 [API] Refresh failed. Force Logout.');
|
||||
_forceLogout();
|
||||
}
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _forceLogout() {
|
||||
try {
|
||||
final authBloc = getIt<AuthBloc>();
|
||||
authBloc.add(const AuthLogoutRequested());
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ApiClient: force logout failed - ${ErrorHandler.getErrorMessage(e)}',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refreshToken() async {
|
||||
try {
|
||||
final authService = getIt<KeycloakAuthService>();
|
||||
final newToken = await authService.refreshToken();
|
||||
return newToken != null;
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ApiClient: refresh token failed - ${ErrorHandler.getErrorMessage(e)}',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? queryParameters, Options? options}) => _dio.get<T>(path, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.post<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> put<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.put<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.delete<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
21
lib/core/network/network_info.dart
Normal file
21
lib/core/network/network_info.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// Interface pour vérifier la connectivité réseau
|
||||
abstract class NetworkInfo {
|
||||
Future<bool> get isConnected;
|
||||
}
|
||||
|
||||
/// Implémentation de NetworkInfo utilisant connectivity_plus
|
||||
@LazySingleton(as: NetworkInfo)
|
||||
class NetworkInfoImpl implements NetworkInfo {
|
||||
final Connectivity connectivity;
|
||||
|
||||
NetworkInfoImpl(this.connectivity);
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
final result = await connectivity.checkConnectivity();
|
||||
return result.any((r) => r != ConnectivityResult.none);
|
||||
}
|
||||
}
|
||||
169
lib/core/network/offline_manager.dart
Normal file
169
lib/core/network/offline_manager.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
/// Offline-first manager for connectivity monitoring and operation queueing
|
||||
library offline_manager;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../storage/pending_operations_store.dart';
|
||||
import '../utils/logger.dart' show AppLogger;
|
||||
|
||||
/// Status of network connectivity
|
||||
enum ConnectivityStatus {
|
||||
online,
|
||||
offline,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Offline manager that monitors connectivity and manages offline operations
|
||||
@singleton
|
||||
class OfflineManager {
|
||||
final Connectivity _connectivity;
|
||||
final PendingOperationsStore _operationsStore;
|
||||
|
||||
ConnectivityStatus _currentStatus = ConnectivityStatus.unknown;
|
||||
final _statusController = StreamController<ConnectivityStatus>.broadcast();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
OfflineManager(
|
||||
this._connectivity,
|
||||
this._operationsStore,
|
||||
) {
|
||||
_initConnectivityMonitoring();
|
||||
}
|
||||
|
||||
/// Current connectivity status
|
||||
ConnectivityStatus get currentStatus => _currentStatus;
|
||||
|
||||
/// Stream of connectivity status changes
|
||||
Stream<ConnectivityStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Check if device is currently online
|
||||
Future<bool> get isOnline async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
return result.any((r) => r != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
/// Initialize connectivity monitoring
|
||||
void _initConnectivityMonitoring() {
|
||||
// Check initial connectivity
|
||||
_connectivity.checkConnectivity().then((result) {
|
||||
_updateStatus(result);
|
||||
});
|
||||
|
||||
// Listen for connectivity changes
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
_updateStatus,
|
||||
onError: (error) {
|
||||
AppLogger.error('Connectivity monitoring error', error: error);
|
||||
_updateStatus([ConnectivityResult.none]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Update connectivity status
|
||||
void _updateStatus(List<ConnectivityResult> results) {
|
||||
final isConnected = results.any((r) => r != ConnectivityResult.none);
|
||||
final newStatus = isConnected
|
||||
? ConnectivityStatus.online
|
||||
: ConnectivityStatus.offline;
|
||||
|
||||
if (newStatus != _currentStatus) {
|
||||
final previousStatus = _currentStatus;
|
||||
_currentStatus = newStatus;
|
||||
_statusController.add(newStatus);
|
||||
|
||||
AppLogger.info('Connectivity changed: $previousStatus → $newStatus');
|
||||
|
||||
// When back online, process pending operations
|
||||
if (newStatus == ConnectivityStatus.online &&
|
||||
previousStatus == ConnectivityStatus.offline) {
|
||||
_processPendingOperations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an operation for later retry when offline
|
||||
Future<void> queueOperation({
|
||||
required String operationType,
|
||||
required String endpoint,
|
||||
required Map<String, dynamic> data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
await _operationsStore.addPendingOperation(
|
||||
operationType: operationType,
|
||||
endpoint: endpoint,
|
||||
data: data,
|
||||
headers: headers,
|
||||
);
|
||||
AppLogger.info('Operation queued: $operationType on $endpoint');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to queue operation', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all pending operations when back online
|
||||
Future<void> _processPendingOperations() async {
|
||||
AppLogger.info('Processing pending operations...');
|
||||
|
||||
try {
|
||||
final operations = await _operationsStore.getPendingOperations();
|
||||
|
||||
if (operations.isEmpty) {
|
||||
AppLogger.info('No pending operations to process');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.info('Found ${operations.length} pending operations');
|
||||
|
||||
// Process operations one by one
|
||||
for (final operation in operations) {
|
||||
try {
|
||||
// Note: Actual retry logic is delegated to the calling code
|
||||
// This manager only provides the queuing mechanism
|
||||
AppLogger.info('Pending operation ready for retry: ${operation['operationType']}');
|
||||
} catch (e) {
|
||||
AppLogger.error('Error processing pending operation', error: e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to process pending operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually trigger processing of pending operations
|
||||
Future<void> retryPendingOperations() async {
|
||||
if (_currentStatus == ConnectivityStatus.online) {
|
||||
await _processPendingOperations();
|
||||
} else {
|
||||
AppLogger.warning('Cannot retry pending operations while offline');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending operations
|
||||
Future<void> clearPendingOperations() async {
|
||||
try {
|
||||
await _operationsStore.clearAll();
|
||||
AppLogger.info('Pending operations cleared');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to clear pending operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of pending operations
|
||||
Future<int> getPendingOperationsCount() async {
|
||||
try {
|
||||
final operations = await _operationsStore.getPendingOperations();
|
||||
return operations.length;
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get pending operations count', error: e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_statusController.close();
|
||||
}
|
||||
}
|
||||
160
lib/core/network/retry_policy.dart
Normal file
160
lib/core/network/retry_policy.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
/// Retry policy with exponential backoff for network requests
|
||||
library retry_policy;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
/// Configuration for retry behavior
|
||||
class RetryConfig {
|
||||
/// Maximum number of retry attempts
|
||||
final int maxAttempts;
|
||||
|
||||
/// Initial delay before first retry (milliseconds)
|
||||
final int initialDelayMs;
|
||||
|
||||
/// Maximum delay between retries (milliseconds)
|
||||
final int maxDelayMs;
|
||||
|
||||
/// Multiplier for exponential backoff
|
||||
final double backoffMultiplier;
|
||||
|
||||
/// Whether to add jitter to retry delays
|
||||
final bool useJitter;
|
||||
|
||||
const RetryConfig({
|
||||
this.maxAttempts = 3,
|
||||
this.initialDelayMs = 1000,
|
||||
this.maxDelayMs = 30000,
|
||||
this.backoffMultiplier = 2.0,
|
||||
this.useJitter = true,
|
||||
});
|
||||
|
||||
/// Default configuration for standard API calls
|
||||
static const standard = RetryConfig();
|
||||
|
||||
/// Configuration for critical operations
|
||||
static const critical = RetryConfig(
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 60000,
|
||||
);
|
||||
|
||||
/// Configuration for background sync
|
||||
static const backgroundSync = RetryConfig(
|
||||
maxAttempts: 10,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 120000,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retry policy implementation with exponential backoff
|
||||
class RetryPolicy {
|
||||
final RetryConfig config;
|
||||
final Random _random = Random();
|
||||
|
||||
RetryPolicy({RetryConfig? config}) : config = config ?? RetryConfig.standard;
|
||||
|
||||
/// Executes an operation with retry logic
|
||||
///
|
||||
/// [operation]: The async operation to execute
|
||||
/// [shouldRetry]: Optional function to determine if error is retryable
|
||||
/// [onRetry]: Optional callback when retry attempt is made
|
||||
///
|
||||
/// Returns the result of the operation
|
||||
/// Throws the last error if all retries fail
|
||||
Future<T> execute<T>({
|
||||
required Future<T> Function() operation,
|
||||
bool Function(dynamic error)? shouldRetry,
|
||||
void Function(int attempt, dynamic error, Duration delay)? onRetry,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
dynamic lastError;
|
||||
|
||||
while (attempt < config.maxAttempts) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempt++;
|
||||
|
||||
// Check if we should retry this error
|
||||
final retryable = shouldRetry?.call(error) ?? _isRetryableError(error);
|
||||
|
||||
if (!retryable || attempt >= config.maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
final delay = _calculateDelay(attempt);
|
||||
|
||||
// Notify about retry
|
||||
onRetry?.call(attempt, error, delay);
|
||||
|
||||
// Wait before next attempt
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but throw last error just in case
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/// Calculates delay for given attempt number
|
||||
Duration _calculateDelay(int attempt) {
|
||||
// Exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
final exponentialDelay = config.initialDelayMs *
|
||||
pow(config.backoffMultiplier, attempt - 1).toInt();
|
||||
|
||||
// Cap at max delay
|
||||
var delayMs = min(exponentialDelay, config.maxDelayMs);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
if (config.useJitter) {
|
||||
final jitter = _random.nextDouble() * 0.3; // ±30% jitter
|
||||
delayMs = (delayMs * (1 + jitter - 0.15)).toInt();
|
||||
}
|
||||
|
||||
return Duration(milliseconds: delayMs);
|
||||
}
|
||||
|
||||
/// Determines if an error is retryable
|
||||
bool _isRetryableError(dynamic error) {
|
||||
// Network errors are retryable
|
||||
if (error is TimeoutException) return true;
|
||||
if (error is SocketException) return true;
|
||||
|
||||
// HTTP status codes that are retryable
|
||||
if (error.toString().contains('500')) return true; // Internal Server Error
|
||||
if (error.toString().contains('502')) return true; // Bad Gateway
|
||||
if (error.toString().contains('503')) return true; // Service Unavailable
|
||||
if (error.toString().contains('504')) return true; // Gateway Timeout
|
||||
if (error.toString().contains('429')) return true; // Too Many Requests
|
||||
|
||||
// Client errors (4xx) are generally not retryable
|
||||
if (error.toString().contains('400')) return false; // Bad Request
|
||||
if (error.toString().contains('401')) return false; // Unauthorized
|
||||
if (error.toString().contains('403')) return false; // Forbidden
|
||||
if (error.toString().contains('404')) return false; // Not Found
|
||||
|
||||
// Default: don't retry unknown errors
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to add retry capability to Future operations
|
||||
extension RetryExtension<T> on Future<T> Function() {
|
||||
/// Executes this operation with retry logic
|
||||
Future<T> withRetry({
|
||||
RetryConfig? config,
|
||||
bool Function(dynamic error)? shouldRetry,
|
||||
void Function(int attempt, dynamic error, Duration delay)? onRetry,
|
||||
}) {
|
||||
final policy = RetryPolicy(config: config);
|
||||
return policy.execute(
|
||||
operation: this,
|
||||
shouldRetry: shouldRetry,
|
||||
onRetry: onRetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user