feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View 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);
}

View File

@@ -1,214 +0,0 @@
/// Client HTTP Dio configuré pour l'API UnionFlow
library dio_client;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/environment.dart';
/// Configuration du client HTTP Dio
class DioClient {
static const int _connectTimeout = 30000; // 30 secondes
static const int _receiveTimeout = 30000; // 30 secondes
static const int _sendTimeout = 30000; // 30 secondes
late final Dio _dio;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
DioClient() {
_dio = Dio();
_configureDio();
}
/// Configuration du client Dio
void _configureDio() {
// Configuration de base - URL depuis AppConfig
_dio.options = BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(milliseconds: _connectTimeout),
receiveTimeout: const Duration(milliseconds: _receiveTimeout),
sendTimeout: const Duration(milliseconds: _sendTimeout),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
// Intercepteur d'authentification
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// Ajouter le token d'authentification si disponible
final token = await _secureStorage.read(key: 'keycloak_webview_access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
// Gestion des erreurs d'authentification
if (error.response?.statusCode == 401) {
// Token expiré, essayer de le rafraîchir
final refreshed = await _refreshToken();
if (refreshed) {
// Réessayer la requête avec le nouveau token
final token = await _secureStorage.read(key: 'keycloak_webview_access_token');
if (token != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(error.requestOptions);
handler.resolve(response);
return;
}
}
}
handler.next(error);
},
));
// Logger uniquement en mode développement
if (AppConfig.enableLogging) {
_dio.interceptors.add(
LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
logPrint: (obj) => print('DIO: $obj'),
),
);
}
}
/// Rafraîchit le token d'authentification
Future<bool> _refreshToken() async {
try {
final refreshToken = await _secureStorage.read(key: 'keycloak_webview_refresh_token');
if (refreshToken == null) return false;
final response = await Dio().post(
AppConfig.keycloakTokenUrl,
data: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': 'unionflow-mobile',
},
options: Options(
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
),
);
if (response.statusCode == 200) {
final data = response.data;
await _secureStorage.write(key: 'keycloak_webview_access_token', value: data['access_token']);
if (data['refresh_token'] != null) {
await _secureStorage.write(key: 'keycloak_webview_refresh_token', value: data['refresh_token']);
}
return true;
}
} catch (e) {
// Erreur lors du rafraîchissement, l'utilisateur devra se reconnecter
}
return false;
}
/// Obtient l'instance Dio configurée
Dio get dio => _dio;
/// Méthodes de convenance pour les requêtes HTTP
/// GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) {
return _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
}
/// POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// PATCH request
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:injectable/injectable.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
/// Interface pour vérifier la connectivité réseau
@@ -6,6 +7,7 @@ abstract class NetworkInfo {
}
/// Implémentation de NetworkInfo utilisant connectivity_plus
@LazySingleton(as: NetworkInfo)
class NetworkInfoImpl implements NetworkInfo {
final Connectivity connectivity;

View 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();
}
}

View 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,
);
}
}