Documentation ajoutée : - ARCHITECTURE.md : Clean Architecture par feature, BLoC pattern - OPTIMISATIONS_PERFORMANCE.md : Cache multi-niveaux, pagination, lazy loading - SECURITE_PRODUCTION.md : FlutterSecureStorage, JWT, HTTPS, ProGuard - CHANGELOG.md : Historique versions - CONTRIBUTING.md : Guide contribution - README.md : Mise à jour (build, env config) Widgets partagés : - file_upload_widget.dart : Upload fichiers (photos/PDFs) Cache : - lib/core/cache/ : Système cache L1/L2 (mémoire/disque) Dependencies : - pubspec.yaml : file_picker 8.1.2, injectable, dio Spec 001 : 27/27 tâches (100%) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1011 lines
28 KiB
Markdown
1011 lines
28 KiB
Markdown
# 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<Object?> get props => [];
|
|
}
|
|
|
|
class RefreshDashboard extends DashboardEvent {
|
|
@override
|
|
List<Object?> get props => [];
|
|
}
|
|
```
|
|
|
|
#### 2. States (Sorties)
|
|
|
|
```dart
|
|
// features/dashboard/presentation/bloc/dashboard_state.dart
|
|
abstract class DashboardState extends Equatable {}
|
|
|
|
class DashboardInitial extends DashboardState {
|
|
@override
|
|
List<Object?> get props => [];
|
|
}
|
|
|
|
class DashboardLoading extends DashboardState {
|
|
@override
|
|
List<Object?> get props => [];
|
|
}
|
|
|
|
class DashboardLoaded extends DashboardState {
|
|
final DashboardEntity dashboard;
|
|
|
|
DashboardLoaded(this.dashboard);
|
|
|
|
@override
|
|
List<Object?> get props => [dashboard];
|
|
}
|
|
|
|
class DashboardError extends DashboardState {
|
|
final String message;
|
|
|
|
DashboardError(this.message);
|
|
|
|
@override
|
|
List<Object?> get props => [message];
|
|
}
|
|
```
|
|
|
|
#### 3. BLoC (Logique)
|
|
|
|
```dart
|
|
// features/dashboard/presentation/bloc/dashboard_bloc.dart
|
|
class DashboardBloc extends Bloc<DashboardEvent, DashboardState> {
|
|
final GetDashboardData getDashboardData;
|
|
|
|
DashboardBloc({required this.getDashboardData}) : super(DashboardInitial()) {
|
|
on<LoadDashboardData>(_onLoadDashboard);
|
|
on<RefreshDashboard>(_onRefreshDashboard);
|
|
}
|
|
|
|
Future<void> _onLoadDashboard(
|
|
LoadDashboardData event,
|
|
Emitter<DashboardState> emit,
|
|
) async {
|
|
emit(DashboardLoading());
|
|
|
|
final result = await getDashboardData();
|
|
|
|
result.fold(
|
|
(failure) => emit(DashboardError(failure.message)),
|
|
(dashboard) => emit(DashboardLoaded(dashboard)),
|
|
);
|
|
}
|
|
|
|
Future<void> _onRefreshDashboard(
|
|
RefreshDashboard event,
|
|
Emitter<DashboardState> 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<DashboardBloc>()..add(LoadDashboardData()),
|
|
child: BlocBuilder<DashboardBloc, DashboardState>(
|
|
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<Either<Failure, DashboardEntity>> 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<Either<Failure, DashboardEntity>> getDashboardData() async {
|
|
try {
|
|
// 1. Check cache
|
|
final cached = cacheService.get<DashboardStatsModel>('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<DashboardBloc>().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<Failure, Entity>
|
|
↓
|
|
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<void> 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<SharedPreferences> 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<DashboardBloc, DashboardState>(
|
|
builder: (context, state) {
|
|
if (state is DashboardLoaded) {
|
|
return DashboardView(data: state.dashboard);
|
|
}
|
|
return LoadingWidget();
|
|
},
|
|
)
|
|
```
|
|
|
|
**BlocListener** - Side effects (navigation, snackbar, etc.):
|
|
```dart
|
|
BlocListener<AuthBloc, AuthState>(
|
|
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<DashboardBloc, DashboardState>(
|
|
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<KeycloakAuthService>().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<KeycloakAuthService>().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<String, dynamic> _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<String, int> _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<String?>? _refreshFuture;
|
|
|
|
Future<String?> 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<dynamic>.broadcast();
|
|
|
|
Stream<dynamic> 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<DashboardEvent, DashboardState> {
|
|
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<void> 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<DashboardBloc, DashboardState>(
|
|
'emits [Loading, Loaded] when LoadDashboardData succeeds',
|
|
build: () => DashboardBloc(getDashboardData: mockGetDashboardData),
|
|
act: (bloc) => bloc.add(LoadDashboardData()),
|
|
expect: () => [
|
|
DashboardLoading(),
|
|
DashboardLoaded(tDashboard),
|
|
],
|
|
);
|
|
```
|
|
|
|
### 4. Error Handling
|
|
|
|
**Utiliser Either<L, R>** (package `dartz`):
|
|
```dart
|
|
Future<Either<Failure, Entity>> 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*
|