# Architecture UnionFlow Mobile Document de référence de l'architecture logicielle de l'application UnionFlow Mobile. **Version**: 1.0.0 **Date**: 2026-03-15 **Pattern**: Clean Architecture + BLoC --- ## Table des Matières 1. [Vue d'Ensemble](#vue-densemble) 2. [Clean Architecture](#clean-architecture) 3. [BLoC Pattern](#bloc-pattern) 4. [Structure des Dossiers](#structure-des-dossiers) 5. [Couches Applicatives](#couches-applicatives) 6. [Flux de Données](#flux-de-données) 7. [Injection de Dépendances](#injection-de-dépendances) 8. [Gestion d'État](#gestion-détat) 9. [Communication Réseau](#communication-réseau) 10. [Cache Strategy](#cache-strategy) 11. [Authentification](#authentification) 12. [WebSocket Temps Réel](#websocket-temps-réel) 13. [Bonnes Pratiques](#bonnes-pratiques) --- ## Vue d'Ensemble UnionFlow Mobile est construit selon les principes de **Clean Architecture** de Robert C. Martin, couplée au pattern **BLoC (Business Logic Component)** pour la gestion d'état. ### Objectifs Architecturaux ✅ **Séparation des préoccupations** - Chaque couche a une responsabilité unique ✅ **Testabilité** - Code découplé et facilement testable ✅ **Maintenabilité** - Structure claire et cohérente ✅ **Scalabilité** - Ajout facile de nouvelles fonctionnalités ✅ **Indépendance du framework** - Business logic indépendante de Flutter --- ## Clean Architecture ### Les 3 Couches Principales ``` ┌─────────────────────────────────────────────────────┐ │ PRESENTATION │ ← UI, BLoC, Pages, Widgets │ (Flutter Widgets, BLoC, Events, States) │ ├─────────────────────────────────────────────────────┤ │ DOMAIN │ ← Business Logic │ (Entities, Use Cases, Repository Interfaces) │ ├─────────────────────────────────────────────────────┤ │ DATA │ ← Data Sources │ (Models, Repositories Impl, API, Cache, DB) │ └─────────────────────────────────────────────────────┘ ``` ### Règles de Dépendance **Règle d'or**: Les dépendances pointent toujours **vers l'intérieur**. - ✅ Presentation → Domain → Data - ❌ Data → Domain (INTERDIT) - ❌ Domain → Presentation (INTERDIT) **Exemple**: ```dart // ✅ BON: Presentation dépend de Domain class DashboardBloc { final GetDashboardData useCase; // Use case du Domain } // ✅ BON: Data implémente interface du Domain class DashboardRepositoryImpl implements DashboardRepository { // Repository du Domain } // ❌ MAUVAIS: Domain dépend de Data class GetDashboardData { final DashboardRemoteDataSource dataSource; // Data layer - INTERDIT } ``` --- ## BLoC Pattern ### Principe **BLoC** = Business Logic Component Le BLoC est un pattern de gestion d'état qui: - Sépare la logique métier de l'UI - Utilise des **Events** (entrées) et des **States** (sorties) - Communication via **Streams** (reactive programming) ### Schéma de Flux ``` ┌─────────┐ Event ┌──────┐ State ┌────────┐ │ Widget │ ──────────────────> │ BLoC │ ──────────────────> │ Widget │ └─────────┘ └──────┘ └────────┘ │ │ │ ├─> Use Cases (Domain) │ ├─> Repository (Domain) │ └─> Data Sources (Data) │ └─────────────────────────────────────────────────────────┘ (BlocBuilder reconstruit l'UI) ``` ### Composants d'un BLoC #### 1. Events (Entrées) ```dart // features/dashboard/presentation/bloc/dashboard_event.dart abstract class DashboardEvent extends Equatable {} class LoadDashboardData extends DashboardEvent { @override List get props => []; } class RefreshDashboard extends DashboardEvent { @override List get props => []; } ``` #### 2. States (Sorties) ```dart // features/dashboard/presentation/bloc/dashboard_state.dart abstract class DashboardState extends Equatable {} class DashboardInitial extends DashboardState { @override List get props => []; } class DashboardLoading extends DashboardState { @override List get props => []; } class DashboardLoaded extends DashboardState { final DashboardEntity dashboard; DashboardLoaded(this.dashboard); @override List get props => [dashboard]; } class DashboardError extends DashboardState { final String message; DashboardError(this.message); @override List get props => [message]; } ``` #### 3. BLoC (Logique) ```dart // features/dashboard/presentation/bloc/dashboard_bloc.dart class DashboardBloc extends Bloc { final GetDashboardData getDashboardData; DashboardBloc({required this.getDashboardData}) : super(DashboardInitial()) { on(_onLoadDashboard); on(_onRefreshDashboard); } Future _onLoadDashboard( LoadDashboardData event, Emitter emit, ) async { emit(DashboardLoading()); final result = await getDashboardData(); result.fold( (failure) => emit(DashboardError(failure.message)), (dashboard) => emit(DashboardLoaded(dashboard)), ); } Future _onRefreshDashboard( RefreshDashboard event, Emitter emit, ) async { // Similar logic with cache invalidation } } ``` #### 4. UI (Consommation) ```dart // features/dashboard/presentation/pages/dashboard_page.dart class DashboardPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt()..add(LoadDashboardData()), child: BlocBuilder( builder: (context, state) { if (state is DashboardLoading) { return CircularProgressIndicator(); } else if (state is DashboardLoaded) { return _buildDashboard(state.dashboard); } else if (state is DashboardError) { return ErrorWidget(state.message); } return SizedBox.shrink(); }, ), ); } } ``` --- ## Structure des Dossiers ``` lib/ ├── core/ # Code transversal │ ├── cache/ │ │ ├── cache_service.dart # Service cache TTL │ │ └── cached_datasource_decorator.dart │ ├── config/ │ │ └── environment.dart # Configuration ENV │ ├── constants/ │ │ └── app_constants.dart # Constantes app │ ├── di/ │ │ ├── injection_container.dart # Setup DI │ │ ├── injection.dart # @module annotations │ │ └── register_module.dart │ ├── navigation/ │ │ ├── app_router.dart # go_router config │ │ └── main_navigation_layout.dart │ ├── network/ │ │ ├── api_client.dart # Dio setup │ │ └── network_info.dart # Connectivity │ ├── storage/ │ │ └── dashboard_cache_manager.dart │ └── utils/ │ └── logger.dart # AppLogger central │ ├── features/ # Modules métier │ ├── authentication/ │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ │ ├── keycloak_auth_service.dart │ │ │ │ ├── keycloak_webview_auth_service.dart │ │ │ │ └── permission_engine.dart │ │ │ ├── models/ │ │ │ │ ├── user.dart │ │ │ │ └── user_role.dart │ │ │ └── repositories/ │ │ │ └── auth_repository_impl.dart │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ │ └── user_entity.dart │ │ │ ├── repositories/ │ │ │ │ └── auth_repository.dart │ │ │ └── usecases/ │ │ │ ├── login_user.dart │ │ │ ├── logout_user.dart │ │ │ └── get_current_user.dart │ │ └── presentation/ │ │ ├── bloc/ │ │ │ ├── auth_bloc.dart │ │ │ ├── auth_event.dart │ │ │ └── auth_state.dart │ │ └── pages/ │ │ └── login_page.dart │ │ │ ├── dashboard/ # Feature Dashboard │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ │ └── dashboard_remote_datasource.dart │ │ │ ├── models/ │ │ │ │ ├── dashboard_stats_model.dart │ │ │ │ └── dashboard_stats_model.g.dart │ │ │ ├── repositories/ │ │ │ │ └── dashboard_repository_impl.dart │ │ │ └── services/ │ │ │ ├── dashboard_offline_service.dart │ │ │ └── dashboard_performance_monitor.dart │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ │ └── dashboard_entity.dart │ │ │ ├── repositories/ │ │ │ │ └── dashboard_repository.dart │ │ │ └── usecases/ │ │ │ └── get_dashboard_data.dart │ │ └── presentation/ │ │ ├── bloc/ │ │ │ ├── dashboard_bloc.dart │ │ │ ├── dashboard_event.dart │ │ │ └── dashboard_state.dart │ │ ├── pages/ │ │ │ ├── connected_dashboard_page.dart │ │ │ └── role_dashboards/ │ │ │ ├── super_admin_dashboard.dart │ │ │ ├── org_admin_dashboard.dart │ │ │ ├── active_member_dashboard.dart │ │ │ └── ... │ │ └── widgets/ │ │ ├── dashboard_stat_card.dart │ │ └── dashboard_chart.dart │ │ │ ├── members/ # Feature Membres │ ├── contributions/ # Feature Cotisations │ ├── events/ # Feature Événements │ ├── solidarity/ # Feature Solidarité │ ├── organizations/ # Feature Organisations │ ├── notifications/ # Feature Notifications │ ├── profile/ # Feature Profil │ ├── reports/ # Feature Rapports │ └── admin/ # Feature Administration │ ├── shared/ # Composants UI partagés │ ├── design_system/ │ │ ├── components/ │ │ │ ├── buttons/ │ │ │ │ ├── uf_primary_button.dart │ │ │ │ └── uf_secondary_button.dart │ │ │ ├── cards/ │ │ │ │ ├── uf_card.dart │ │ │ │ ├── uf_stat_card.dart │ │ │ │ └── uf_metric_card.dart │ │ │ ├── inputs/ │ │ │ └── uf_app_bar.dart │ │ └── theme/ │ │ └── app_theme_sophisticated.dart │ └── widgets/ │ ├── confirmation_dialog.dart │ └── file_upload_widget.dart │ └── main.dart # Point d'entrée ``` --- ## Couches Applicatives ### 1. Presentation Layer (UI) **Responsabilités**: - Afficher l'interface utilisateur - Écouter les interactions utilisateur - Envoyer des **Events** au BLoC - Observer les **States** du BLoC - Déclencher la navigation **Fichiers**: - `bloc/` - Gestion d'état (Events, States, BLoC) - `pages/` - Écrans de l'app - `widgets/` - Composants UI réutilisables **Règles**: - ❌ Pas de logique métier dans les widgets - ❌ Pas d'appels directs aux repositories ou data sources - ✅ Communication uniquement via BLoC - ✅ Widgets stateless autant que possible ### 2. Domain Layer (Business Logic) **Responsabilités**: - Définir les **Entities** (objets métier purs) - Définir les **Use Cases** (actions métier) - Définir les **Repository Interfaces** (contrats) **Fichiers**: - `entities/` - Objets métier purs (sans annotation) - `usecases/` - Actions métier atomiques - `repositories/` - Interfaces (abstractions) **Règles**: - ✅ Code 100% Dart pur (pas de dépendance Flutter) - ✅ Facilement testable unitairement - ✅ Indépendant des frameworks externes - ❌ Pas de dépendance vers Data ou Presentation **Exemple Use Case**: ```dart // domain/usecases/get_dashboard_data.dart class GetDashboardData { final DashboardRepository repository; GetDashboardData(this.repository); Future> call() async { return await repository.getDashboardData(); } } ``` ### 3. Data Layer (Data Sources) **Responsabilités**: - Implémenter les **Repository Interfaces** du Domain - Gérer les sources de données (API, Cache, DB) - Mapper les **Models** vers les **Entities** **Fichiers**: - `datasources/` - Services API, Cache, DB - `models/` - DTOs avec serialization JSON - `repositories/` - Implémentations des interfaces **Règles**: - ✅ Utilise `json_annotation` pour serialization - ✅ Gère les erreurs réseau et parsing - ✅ Implémente le cache strategy - ❌ Pas d'exposition des Models hors de Data layer **Exemple Repository**: ```dart // data/repositories/dashboard_repository_impl.dart class DashboardRepositoryImpl implements DashboardRepository { final DashboardRemoteDataSource remoteDataSource; final CacheService cacheService; DashboardRepositoryImpl({ required this.remoteDataSource, required this.cacheService, }); @override Future> getDashboardData() async { try { // 1. Check cache final cached = cacheService.get('dashboard_stats'); if (cached != null) { return Right(cached.toEntity()); } // 2. Fetch from API final model = await remoteDataSource.getDashboardStats(); // 3. Cache result await cacheService.set('dashboard_stats', model); // 4. Map to Entity return Right(model.toEntity()); } on DioException catch (e) { return Left(NetworkFailure(e.message ?? 'Network error')); } catch (e) { return Left(UnknownFailure(e.toString())); } } } ``` --- ## Flux de Données ### Flux Complet (Exemple Dashboard) ``` 1. USER ACTION ↓ 2. Widget.onPressed → context.read().add(LoadDashboardData()) ↓ 3. DashboardBloc reçoit l'event ↓ 4. Bloc appelle Use Case: getDashboardData() ↓ 5. Use Case appelle Repository: repository.getDashboardData() ↓ 6. Repository vérifie le cache (CacheService) ├─> Cache HIT: retourne Entity depuis cache └─> Cache MISS: appelle RemoteDataSource ↓ 7. RemoteDataSource fait l'appel API (Dio) ↓ 8. API retourne JSON ↓ 9. JSON désérialisé en Model (@JsonSerializable) ↓ 10. Model mappé en Entity (model.toEntity()) ↓ 11. Repository met en cache (cacheService.set) ↓ 12. Repository retourne Either ↓ 13. Use Case retourne le résultat au Bloc ↓ 14. Bloc émet un nouveau State: DashboardLoaded(entity) ↓ 15. BlocBuilder reconstruit l'UI avec les nouvelles données ``` --- ## Injection de Dépendances ### Get It + Injectable ```dart // core/di/injection_container.dart import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; import 'injection_container.config.dart'; final getIt = GetIt.instance; @InjectableInit() Future configureDependencies() async { await getIt.init(); } ``` ### Annotations ```dart // @singleton - Instance unique partagée @singleton class CacheService { ... } // @lazySingleton - Instance unique créée à la demande @lazySingleton class KeycloakAuthService { ... } // @injectable - Nouvelle instance à chaque injection @injectable class DashboardBloc { final GetDashboardData getDashboardData; DashboardBloc({required this.getDashboardData}); } ``` ### Enregistrement Manuel ```dart // core/di/register_module.dart @module abstract class RegisterModule { @lazySingleton Dio get dio => Dio( BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), ), ); @lazySingleton FlutterSecureStorage get secureStorage => const FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), ); @preResolve Future get sharedPreferences => SharedPreferences.getInstance(); } ``` --- ## Gestion d'État ### BLoC Lifecycle ``` ┌─────────────┐ │ Initial │ ← État initial └──────┬──────┘ │ ├──> Event reçu │ ┌──────▼──────┐ │ Loading │ ← Chargement en cours └──────┬──────┘ │ ├──> Succès │ ┌──────▼──────┐ │ Loaded │ ← Données chargées └──────┬──────┘ │ ├──> Erreur │ ┌──────▼──────┐ │ Error │ ← Erreur survenue └─────────────┘ ``` ### BlocListener vs BlocBuilder **BlocBuilder** - Reconstruction UI: ```dart BlocBuilder( builder: (context, state) { if (state is DashboardLoaded) { return DashboardView(data: state.dashboard); } return LoadingWidget(); }, ) ``` **BlocListener** - Side effects (navigation, snackbar, etc.): ```dart BlocListener( listener: (context, state) { if (state is AuthSuccess) { context.go('/dashboard'); } else if (state is AuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.message)), ); } }, child: LoginForm(), ) ``` **BlocConsumer** - Combinaison des deux: ```dart BlocConsumer( listener: (context, state) { // Side effects }, builder: (context, state) { // UI rebuild }, ) ``` --- ## Communication Réseau ### Dio Client **Configuration** (`core/network/api_client.dart`): ```dart final dio = Dio( BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, ), ); // Interceptor pour ajouter le token JWT dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await getIt().getValidToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } return handler.next(options); }, )); // Interceptor pour gérer les erreurs 401 (token expiré) dio.interceptors.add(InterceptorsWrapper( onError: (error, handler) async { if (error.response?.statusCode == 401) { final newToken = await getIt().refreshToken(); if (newToken != null) { // Retry request with new token error.requestOptions.headers['Authorization'] = 'Bearer $newToken'; return handler.resolve(await dio.fetch(error.requestOptions)); } } return handler.next(error); }, )); ``` ### Error Handling ```dart try { final response = await dio.get('/api/dashboard/stats'); return DashboardStatsModel.fromJson(response.data); } on DioException catch (e) { if (e.type == DioExceptionType.connectionTimeout) { throw NetworkException('Connection timeout'); } else if (e.type == DioExceptionType.receiveTimeout) { throw NetworkException('Receive timeout'); } else if (e.response?.statusCode == 400) { throw ValidationException(e.response?.data['message'] ?? 'Validation error'); } else if (e.response?.statusCode == 500) { throw ServerException('Server error'); } else { throw NetworkException('Unknown network error'); } } ``` --- ## Cache Strategy ### Multi-Level Cache (L1 + L2) **L1 Cache** - Mémoire (Instant): ```dart static final Map _memoryCache = {}; ``` **L2 Cache** - Disque (Persist après redémarrage): ```dart final SharedPreferences _prefs; ``` ### Cache Service avec TTL **TTL configurables** (`core/cache/cache_service.dart`): ```dart static const Map _cacheTTL = { 'dashboard_stats': 300, // 5 minutes 'parametres_lcb_ft': 1800, // 30 minutes 'user_profile': 600, // 10 minutes 'organisations': 3600, // 1 heure 'notifications_count': 60, // 1 minute }; ``` ### Cache-Aside Pattern **Decorator** (`core/cache/cached_datasource_decorator.dart`): ```dart final stats = await decorator.withCache( cacheKey: 'dashboard_stats_${userId}', fetchFunction: () => api.getDashboardStats(), ); ``` **Flux**: 1. Vérifier le cache 2. Si HIT: retourner valeur du cache 3. Si MISS: appeler l'API 4. Mettre en cache le résultat 5. Retourner le résultat --- ## Authentification ### OAuth 2.0 / OIDC avec Keycloak **Flux**: ``` 1. User clique "Se connecter" ↓ 2. KeycloakAuthService.login(username, password) ↓ 3. POST /realms/unionflow/protocol/openid-connect/token ↓ 4. Keycloak retourne: access_token, refresh_token, id_token ↓ 5. Tokens stockés dans FlutterSecureStorage (chiffrés) ↓ 6. JWT décodé pour extraire user info + roles ↓ 7. User Entity créé avec primaryRole (RBAC) ↓ 8. AuthBloc émet AuthSuccess(user) ↓ 9. Navigation vers Dashboard ``` ### Refresh Token Automatique **Verrouillage global** pour éviter appels concurrents: ```dart static Future? _refreshFuture; Future refreshToken() async { if (_refreshFuture != null) { return await _refreshFuture; } _refreshFuture = _performRefresh(); try { return await _refreshFuture; } finally { _refreshFuture = null; } } ``` **Auto-refresh** dans `getCurrentUser()`: ```dart if (JwtDecoder.isExpired(token)) { token = await refreshToken(); if (token == null) return null; } ``` --- ## WebSocket Temps Réel ### Architecture Event-Driven **Backend** (Kafka + WebSocket): - Kafka Topics: `unionflow.dashboard.stats`, `unionflow.notifications.user`, etc. - Consommateur Kafka émet events sur WebSocket (`/ws/dashboard`) **Mobile** (WebSocket Client): ```dart @singleton class WebSocketService { late WebSocketChannel _channel; final _eventController = StreamController.broadcast(); Stream get eventStream => _eventController.stream; void connect() { _channel = WebSocketChannel.connect( Uri.parse(AppConfig.wsDashboardUrl), ); _channel.stream.listen((message) { final event = jsonDecode(message); _eventController.add(event); }); } void disconnect() { _channel.sink.close(); _eventController.close(); } } ``` **Intégration BLoC**: ```dart class DashboardBloc extends Bloc { final WebSocketService webSocketService; StreamSubscription? _wsSubscription; DashboardBloc({required this.webSocketService}) : super(DashboardInitial()) { _wsSubscription = webSocketService.eventStream.listen((event) { if (event['type'] == 'DASHBOARD_STATS_UPDATE') { add(RefreshDashboardFromWebSocket(event['data'])); } }); } @override Future close() { _wsSubscription?.cancel(); return super.close(); } } ``` --- ## Bonnes Pratiques ### 1. Naming Conventions **Fichiers**: - snake_case: `dashboard_bloc.dart`, `user_repository.dart` - Suffixes: `_bloc.dart`, `_event.dart`, `_state.dart`, `_page.dart`, `_widget.dart` **Classes**: - PascalCase: `DashboardBloc`, `DashboardEvent`, `DashboardState` - Suffixes: `Bloc`, `Event`, `State`, `Page`, `Widget`, `Repository`, `UseCase` **Variables/Fonctions**: - camelCase: `getDashboardData`, `currentUser`, `isLoading` ### 2. Code Organization **Un fichier = Une responsabilité**: - ✅ `dashboard_bloc.dart` contient seulement DashboardBloc - ✅ `dashboard_event.dart` contient tous les events - ✅ `dashboard_state.dart` contient tous les states **Grouping**: - Regrouper par feature (vertical slicing) - Pas de dossiers `utils/` fourre-tout - Chaque feature = mini-application autonome ### 3. Testing **Tests Unitaires** (Domain): ```dart test('GetDashboardData should return DashboardEntity on success', () async { when(mockRepository.getDashboardData()).thenAnswer( (_) async => Right(tDashboardEntity), ); final result = await useCase(); expect(result, Right(tDashboardEntity)); verify(mockRepository.getDashboardData()); }); ``` **Tests BLoC**: ```dart blocTest( 'emits [Loading, Loaded] when LoadDashboardData succeeds', build: () => DashboardBloc(getDashboardData: mockGetDashboardData), act: (bloc) => bloc.add(LoadDashboardData()), expect: () => [ DashboardLoading(), DashboardLoaded(tDashboard), ], ); ``` ### 4. Error Handling **Utiliser Either** (package `dartz`): ```dart Future> someMethod() async { try { final result = await api.call(); return Right(result); } catch (e) { return Left(ServerFailure()); } } ``` **Failure Hierarchy**: ```dart abstract class Failure extends Equatable {} class ServerFailure extends Failure {} class NetworkFailure extends Failure {} class CacheFailure extends Failure {} ``` ### 5. Performance ✅ **Const constructors** partout: ```dart const Text('Hello'), const SizedBox(height: 16), const EdgeInsets.all(8), ``` ✅ **ListView.builder** pour listes longues: ```dart ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ItemWidget(items[index]), ) ``` ✅ **Debounce** pour recherche: ```dart Timer? _debounce; void _onSearchChanged(String query) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { // Call API }); } ``` --- ## Conclusion L'architecture UnionFlow Mobile est: ✅ **Modulaire** - Ajout facile de nouvelles features ✅ **Testable** - Code découplé et injectable ✅ **Maintenable** - Structure claire et cohérente ✅ **Scalable** - Supporte la croissance de l'application ✅ **Performante** - Cache multi-niveaux, lazy loading ✅ **Sécurisée** - Authentification robuste, storage chiffré **Documentation associée**: - [README.md](../README.md) - Guide d'installation - [SECURITE_PRODUCTION.md](SECURITE_PRODUCTION.md) - Mesures de sécurité - [OPTIMISATIONS_PERFORMANCE.md](OPTIMISATIONS_PERFORMANCE.md) - Optimisations - [CONTRIBUTING.md](../CONTRIBUTING.md) - Guide de contribution --- *Document maintenu par l'équipe UnionFlow Development Team*