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:
@@ -26,15 +26,15 @@ class AppConfig {
|
||||
case Environment.dev:
|
||||
apiBaseUrl = const String.fromEnvironment(
|
||||
'API_URL',
|
||||
defaultValue: 'http://192.168.1.11:8085',
|
||||
defaultValue: 'http://localhost:8085',
|
||||
);
|
||||
keycloakBaseUrl = const String.fromEnvironment(
|
||||
'KEYCLOAK_URL',
|
||||
defaultValue: 'http://192.168.1.11:8180',
|
||||
defaultValue: 'http://localhost:8180',
|
||||
);
|
||||
wsBaseUrl = const String.fromEnvironment(
|
||||
'WS_URL',
|
||||
defaultValue: 'ws://192.168.1.11:8085',
|
||||
defaultValue: 'ws://localhost:8085',
|
||||
);
|
||||
enableDebugMode = true;
|
||||
enableLogging = true;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/// Constantes LCB-FT (anti-blanchiment) pour l'UI.
|
||||
/// Au-dessus de ce montant, l'origine des fonds est obligatoire côté backend.
|
||||
const double kSeuilOrigineFondsObligatoireXOF = 500000.0;
|
||||
@@ -1,120 +0,0 @@
|
||||
/// Configuration globale de l'injection de dépendances
|
||||
library app_di;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../network/dio_client.dart';
|
||||
import '../network/network_info.dart';
|
||||
import '../../features/organizations/di/organizations_di.dart';
|
||||
import '../../features/members/di/membres_di.dart';
|
||||
import '../../features/events/di/evenements_di.dart';
|
||||
import '../../features/contributions/di/contributions_di.dart';
|
||||
import '../../features/adhesions/di/adhesions_di.dart';
|
||||
import '../../features/solidarity/di/solidarity_di.dart';
|
||||
import '../../features/admin/di/admin_di.dart';
|
||||
import '../../features/dashboard/di/dashboard_di.dart';
|
||||
import '../../features/profile/di/profile_di.dart';
|
||||
import '../../features/notifications/di/notifications_di.dart';
|
||||
import '../../features/reports/di/reports_di.dart';
|
||||
|
||||
/// Gestionnaire global des dépendances
|
||||
class AppDI {
|
||||
static final GetIt _getIt = GetIt.instance;
|
||||
|
||||
/// Initialise toutes les dépendances de l'application
|
||||
static Future<void> initialize() async {
|
||||
// Configuration du client HTTP
|
||||
await _setupNetworking();
|
||||
|
||||
// Configuration des modules
|
||||
await _setupModules();
|
||||
}
|
||||
|
||||
/// Configure les services réseau
|
||||
static Future<void> _setupNetworking() async {
|
||||
// Client Dio
|
||||
final dioClient = DioClient();
|
||||
_getIt.registerSingleton<DioClient>(dioClient);
|
||||
_getIt.registerSingleton<Dio>(dioClient.dio);
|
||||
|
||||
// Network Info (pour l'instant, on simule toujours connecté)
|
||||
_getIt.registerLazySingleton<NetworkInfo>(
|
||||
() => _MockNetworkInfo(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Configure tous les modules de l'application
|
||||
static Future<void> _setupModules() async {
|
||||
// Module Organizations
|
||||
OrganizationsDI.registerDependencies();
|
||||
|
||||
// Module Membres
|
||||
MembresDI.register();
|
||||
|
||||
// Module Événements
|
||||
EvenementsDI.register();
|
||||
|
||||
// Module Contributions
|
||||
registerCotisationsDependencies(_getIt);
|
||||
|
||||
// Module Adhésions
|
||||
registerAdhesionsDependencies(_getIt);
|
||||
|
||||
// Module Solidarité (demandes d'aide)
|
||||
registerSolidarityDependencies(_getIt);
|
||||
|
||||
// Module Admin (gestion utilisateurs SUPER_ADMIN)
|
||||
registerAdminDependencies(_getIt);
|
||||
|
||||
// Module Dashboard
|
||||
DashboardDI.registerDependencies();
|
||||
|
||||
// Module Profil utilisateur
|
||||
ProfileDI.register();
|
||||
|
||||
// Module Notifications
|
||||
NotificationsDI.register();
|
||||
|
||||
// Module Rapports & Analytics
|
||||
ReportsDI.register();
|
||||
}
|
||||
|
||||
/// Nettoie toutes les dépendances
|
||||
static Future<void> dispose() async {
|
||||
// Nettoyer les modules
|
||||
OrganizationsDI.unregisterDependencies();
|
||||
MembresDI.unregister();
|
||||
EvenementsDI.unregister();
|
||||
|
||||
// Nettoyer les services globaux
|
||||
if (_getIt.isRegistered<Dio>()) {
|
||||
_getIt.unregister<Dio>();
|
||||
}
|
||||
if (_getIt.isRegistered<DioClient>()) {
|
||||
_getIt.unregister<DioClient>();
|
||||
}
|
||||
|
||||
// Reset complet
|
||||
await _getIt.reset();
|
||||
}
|
||||
|
||||
/// Obtient l'instance GetIt
|
||||
static GetIt get instance => _getIt;
|
||||
|
||||
/// Obtient le client Dio
|
||||
static Dio get dio => _getIt<Dio>();
|
||||
|
||||
/// Obtient le client Dio wrapper
|
||||
static DioClient get dioClient => _getIt<DioClient>();
|
||||
|
||||
/// Nettoie toutes les dépendances
|
||||
static Future<void> cleanup() async {
|
||||
await _getIt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock de NetworkInfo pour les tests et développement
|
||||
class _MockNetworkInfo implements NetworkInfo {
|
||||
@override
|
||||
Future<bool> get isConnected async => true;
|
||||
}
|
||||
13
unionflow/unionflow-mobile-apps/lib/core/di/injection.dart
Normal file
13
unionflow/unionflow-mobile-apps/lib/core/di/injection.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import 'injection.config.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
@InjectableInit(
|
||||
initializerName: 'init', // default
|
||||
preferRelativeImports: true, // default
|
||||
asExtension: true, // default
|
||||
)
|
||||
void configureDependencies() => getIt.init();
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'app_di.dart';
|
||||
|
||||
/// Service locator global - alias pour faciliter l'utilisation
|
||||
final GetIt sl = AppDI.instance;
|
||||
/// Export getIt for convenience
|
||||
export 'injection.dart' show getIt;
|
||||
|
||||
import 'injection.dart';
|
||||
|
||||
/// Service locator global
|
||||
final GetIt sl = getIt;
|
||||
|
||||
/// Initialise toutes les dépendances de l'application
|
||||
Future<void> initializeDependencies() async {
|
||||
await AppDI.initialize();
|
||||
configureDependencies();
|
||||
}
|
||||
|
||||
/// Nettoie toutes les dépendances
|
||||
/// Nettoie toutes les dépendances (optionnel, pour les tests)
|
||||
Future<void> cleanupDependencies() async {
|
||||
await AppDI.cleanup();
|
||||
await sl.reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@module
|
||||
abstract class RegisterModule {
|
||||
@lazySingleton
|
||||
Connectivity get connectivity => Connectivity();
|
||||
|
||||
@lazySingleton
|
||||
FlutterSecureStorage get storage => const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
@lazySingleton
|
||||
http.Client get httpClient => http.Client();
|
||||
|
||||
@preResolve
|
||||
Future<SharedPreferences> get sharedPreferences => SharedPreferences.getInstance();
|
||||
}
|
||||
@@ -48,3 +48,27 @@ class ValidationException extends AppException {
|
||||
@override
|
||||
String toString() => 'ValidationException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception non autorisé (401)
|
||||
class UnauthorizedException extends AppException {
|
||||
const UnauthorizedException([super.message = 'Non autorisé', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception non trouvé (404)
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException([super.message = 'Ressource non trouvée', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotFoundException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception interdit (403)
|
||||
class ForbiddenException extends AppException {
|
||||
const ForbiddenException([super.message = 'Accès interdit', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'ForbiddenException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import 'package:equatable/equatable.dart';
|
||||
abstract class Failure extends Equatable {
|
||||
final String message;
|
||||
final String? code;
|
||||
final bool isRetryable;
|
||||
final String? userFriendlyMessage;
|
||||
|
||||
const Failure(this.message, [this.code]);
|
||||
const Failure(
|
||||
this.message, [
|
||||
this.code,
|
||||
this.isRetryable = false,
|
||||
this.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code];
|
||||
List<Object?> get props => [message, code, isRetryable, userFriendlyMessage];
|
||||
|
||||
/// Get user-friendly message for display in UI
|
||||
String getUserMessage() => userFriendlyMessage ?? message;
|
||||
|
||||
@override
|
||||
String toString() => 'Failure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
@@ -16,7 +26,12 @@ abstract class Failure extends Equatable {
|
||||
|
||||
/// Échec serveur
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(super.message, [super.code]);
|
||||
const ServerFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = true, // Server errors are retryable
|
||||
super.userFriendlyMessage = 'Le serveur rencontre un problème. Veuillez réessayer.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
@@ -32,7 +47,12 @@ class CacheFailure extends Failure {
|
||||
|
||||
/// Échec de réseau
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message, [super.code]);
|
||||
const NetworkFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = true, // Network errors are retryable
|
||||
super.userFriendlyMessage = 'Pas de connexion Internet. Vérifiez votre réseau.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
@@ -48,7 +68,12 @@ class AuthFailure extends Failure {
|
||||
|
||||
/// Échec de validation
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(super.message, [super.code]);
|
||||
const ValidationFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Validation errors are not retryable
|
||||
super.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
@@ -64,8 +89,55 @@ class PermissionFailure extends Failure {
|
||||
|
||||
/// Échec de données non trouvées
|
||||
class NotFoundFailure extends Failure {
|
||||
const NotFoundFailure(super.message, [super.code]);
|
||||
const NotFoundFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Not found errors are not retryable
|
||||
super.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotFoundFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec non autorisé (401)
|
||||
class UnauthorizedFailure extends Failure {
|
||||
const UnauthorizedFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Auth errors are not retryable
|
||||
super.userFriendlyMessage = 'Votre session a expiré. Veuillez vous reconnecter.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec interdit (403)
|
||||
class ForbiddenFailure extends Failure {
|
||||
const ForbiddenFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Forbidden errors are not retryable
|
||||
super.userFriendlyMessage = 'Vous n\'avez pas les permissions nécessaires.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ForbiddenFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec inattendu
|
||||
class UnexpectedFailure extends Failure {
|
||||
const UnexpectedFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnexpectedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Fonctionnalité non implémentée
|
||||
class NotImplementedFailure extends Failure {
|
||||
const NotImplementedFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotImplementedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/presentation/pages/login_page.dart';
|
||||
import 'main_navigation_layout.dart';
|
||||
|
||||
/// Configuration du routeur principal de l'application
|
||||
class AppRouter {
|
||||
static final GoRouter router = GoRouter(
|
||||
initialLocation: '/',
|
||||
redirect: (context, state) {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
final isAuthenticated = authState is AuthAuthenticated;
|
||||
final isOnLoginPage = state.matchedLocation == '/login';
|
||||
|
||||
// Si pas authentifié et pas sur la page de login, rediriger vers login
|
||||
if (!isAuthenticated && !isOnLoginPage) {
|
||||
return '/login';
|
||||
}
|
||||
|
||||
// Si authentifié et sur la page de login, rediriger vers dashboard
|
||||
if (isAuthenticated && isOnLoginPage) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return null; // Pas de redirection
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: 'main',
|
||||
builder: (context, state) => const MainNavigationLayout(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'more_page.dart';
|
||||
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
import '../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../features/dashboard/presentation/pages/role_dashboards/role_dashboards.dart';
|
||||
import '../../features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard_loader.dart';
|
||||
import '../../features/members/presentation/pages/members_page_wrapper.dart';
|
||||
import '../../features/events/presentation/pages/events_page_wrapper.dart';
|
||||
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
|
||||
@@ -20,6 +22,10 @@ import '../../features/settings/presentation/pages/system_settings_page.dart';
|
||||
import '../../features/backup/presentation/pages/backup_page.dart';
|
||||
import '../../features/logs/presentation/pages/logs_page.dart';
|
||||
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
|
||||
import '../../features/epargne/presentation/pages/epargne_page.dart';
|
||||
|
||||
import '../../features/dashboard/presentation/bloc/dashboard_bloc.dart';
|
||||
import '../di/injection.dart';
|
||||
|
||||
/// Layout principal avec navigation hybride
|
||||
/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées
|
||||
@@ -32,9 +38,27 @@ class MainNavigationLayout extends StatefulWidget {
|
||||
|
||||
class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
int _selectedIndex = 0;
|
||||
List<Widget>? _cachedPages;
|
||||
UserRole? _lastRole;
|
||||
String? _lastUserId;
|
||||
|
||||
/// Obtient le dashboard approprié selon le rôle de l'utilisateur
|
||||
Widget _getDashboardForRole(UserRole role) {
|
||||
Widget _getDashboardForRole(UserRole role, String userId, String? orgId) {
|
||||
// Admin d'organisation sans orgId (organizationContexts vide) : charger /mes puis dashboard
|
||||
if (role == UserRole.orgAdmin && (orgId == null || orgId.isEmpty)) {
|
||||
return OrgAdminDashboardLoader(userId: userId);
|
||||
}
|
||||
return BlocProvider<DashboardBloc>(
|
||||
create: (context) => getIt<DashboardBloc>()
|
||||
..add(LoadDashboardData(
|
||||
organizationId: orgId ?? '',
|
||||
userId: userId,
|
||||
)),
|
||||
child: _buildDashboardView(role),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashboardView(UserRole role) {
|
||||
switch (role) {
|
||||
case UserRole.superAdmin:
|
||||
return const SuperAdminDashboard();
|
||||
@@ -42,6 +66,10 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
return const OrgAdminDashboard();
|
||||
case UserRole.moderator:
|
||||
return const ModeratorDashboard();
|
||||
case UserRole.consultant:
|
||||
return const ConsultantDashboard();
|
||||
case UserRole.hrManager:
|
||||
return const HRManagerDashboard();
|
||||
case UserRole.activeMember:
|
||||
return const ActiveMemberDashboard();
|
||||
case UserRole.simpleMember:
|
||||
@@ -51,13 +79,25 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _getPages(UserRole role) {
|
||||
return [
|
||||
_getDashboardForRole(role),
|
||||
const MembersPageWrapper(), // Wrapper BLoC pour connexion API
|
||||
const EventsPageWrapper(), // Wrapper BLoC pour connexion API
|
||||
const MorePage(), // Page "Plus" qui affiche les options avancées
|
||||
/// Obtient les pages et les met en cache pour éviter les rebuilds inutiles
|
||||
List<Widget> _getPages(UserRole role, String userId, String? orgId) {
|
||||
if (_cachedPages != null && _lastRole == role && _lastUserId == userId) {
|
||||
return _cachedPages!;
|
||||
}
|
||||
|
||||
debugPrint('🔄 [MainNavigationLayout] Initialisation des pages (Role: $role, User: $userId)');
|
||||
_lastRole = role;
|
||||
_lastUserId = userId;
|
||||
|
||||
final canManageMembers = role.hasLevelOrAbove(UserRole.hrManager);
|
||||
|
||||
_cachedPages = [
|
||||
_getDashboardForRole(role, userId, orgId),
|
||||
if (canManageMembers) const MembersPageWrapper(),
|
||||
const EventsPageWrapper(),
|
||||
const MorePage(),
|
||||
];
|
||||
return _cachedPages!;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,14 +110,20 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
);
|
||||
}
|
||||
|
||||
final orgId = state.user.organizationContexts.isNotEmpty
|
||||
? state.user.organizationContexts.first.organizationId
|
||||
: null;
|
||||
final pages = _getPages(state.effectiveRole, state.user.id, orgId);
|
||||
final safeIndex = _selectedIndex >= pages.length ? 0 : _selectedIndex;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorTokens.background,
|
||||
body: SafeArea(
|
||||
top: true, // Respecte le StatusBar
|
||||
bottom: false, // Le BottomNavigationBar gère son propre SafeArea
|
||||
child: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: _getPages(state.effectiveRole),
|
||||
index: safeIndex,
|
||||
children: pages,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
@@ -95,7 +141,7 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
currentIndex: _selectedIndex,
|
||||
currentIndex: safeIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
@@ -109,23 +155,24 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
),
|
||||
unselectedLabelStyle: TypographyTokens.labelSmall,
|
||||
elevation: 0, // Géré par le Container
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
activeIcon: Icon(Icons.dashboard),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.people_outline),
|
||||
activeIcon: Icon(Icons.people),
|
||||
label: 'Membres',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
if (state.effectiveRole.hasLevelOrAbove(UserRole.hrManager))
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.people_outline),
|
||||
activeIcon: Icon(Icons.people),
|
||||
label: 'Membres',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.event_outlined),
|
||||
activeIcon: Icon(Icons.event),
|
||||
label: 'Événements',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
activeIcon: Icon(Icons.more_horiz),
|
||||
label: 'Plus',
|
||||
@@ -139,399 +186,3 @@ class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Page "Plus" avec les fonctions avancées selon le rôle
|
||||
class MorePage extends StatelessWidget {
|
||||
const MorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AuthAuthenticated) {
|
||||
return Container(
|
||||
color: const Color(0xFFF8F9FA),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: ColorTokens.background,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la section
|
||||
Text(
|
||||
'Plus d\'Options',
|
||||
style: TypographyTokens.headlineMedium.copyWith(
|
||||
color: ColorTokens.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
|
||||
// Profil utilisateur
|
||||
_buildUserProfile(state),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Options selon le rôle
|
||||
..._buildRoleBasedOptions(context, state),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Options communes
|
||||
..._buildCommonOptions(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserProfile(AuthAuthenticated state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6C5CE7),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${state.user.firstName} ${state.user.lastName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
state.effectiveRole.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6C5CE7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
state.user.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) {
|
||||
final options = <Widget>[];
|
||||
|
||||
// Options Super Admin uniquement
|
||||
if (state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Administration Système'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.people,
|
||||
title: 'Gestion des utilisateurs',
|
||||
subtitle: 'Utilisateurs Keycloak et rôles',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const UserManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.settings,
|
||||
title: 'Paramètres Système',
|
||||
subtitle: 'Configuration globale',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SystemSettingsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.backup,
|
||||
title: 'Sauvegarde & Restauration',
|
||||
subtitle: 'Gestion des sauvegardes',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const BackupPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.article,
|
||||
title: 'Logs & Monitoring',
|
||||
subtitle: 'Surveillance et journaux',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Options Admin+ (Admin Organisation et Super Admin)
|
||||
if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Rapports & Analytics'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.assessment,
|
||||
title: 'Rapports & Analytics',
|
||||
subtitle: 'Statistiques détaillées',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ReportsPageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
List<Widget> _buildCommonOptions(BuildContext context) {
|
||||
return [
|
||||
_buildSectionTitle('Général'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.payment,
|
||||
title: 'Cotisations',
|
||||
subtitle: 'Gérer les cotisations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationsPageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.how_to_reg,
|
||||
title: 'Demandes d\'adhésion',
|
||||
subtitle: 'Demandes d\'adhésion à une organisation',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const AdhesionsPageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.volunteer_activism,
|
||||
title: 'Demandes d\'aide',
|
||||
subtitle: 'Solidarité – demandes d\'aide',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DemandesAidePageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.person,
|
||||
title: 'Mon Profil',
|
||||
subtitle: 'Modifier mes informations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ProfilePageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.notifications,
|
||||
title: 'Notifications',
|
||||
subtitle: 'Gérer les notifications',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NotificationsPageWrapper(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.help,
|
||||
title: 'Aide & Support',
|
||||
subtitle: 'Documentation et support',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const HelpSupportPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.info,
|
||||
title: 'À propos',
|
||||
subtitle: 'Version et informations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const AboutPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildOptionTile(
|
||||
icon: Icons.logout,
|
||||
title: 'Déconnexion',
|
||||
subtitle: 'Se déconnecter de l\'application',
|
||||
color: Colors.red,
|
||||
onTap: () {
|
||||
context.read<AuthBloc>().add(const AuthLogoutRequested());
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
Color? color,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: (color ?? const Color(0xFF6C5CE7)).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color ?? const Color(0xFF6C5CE7),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color ?? const Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Color(0xFF6B7280),
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
import '../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../shared/widgets/core_card.dart';
|
||||
import '../../shared/widgets/mini_avatar.dart';
|
||||
|
||||
import '../../features/admin/presentation/pages/user_management_page.dart';
|
||||
import '../../features/settings/presentation/pages/system_settings_page.dart';
|
||||
import '../../features/backup/presentation/pages/backup_page.dart';
|
||||
import '../../features/logs/presentation/pages/logs_page.dart';
|
||||
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
|
||||
import '../../features/epargne/presentation/pages/epargne_page.dart';
|
||||
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
|
||||
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
|
||||
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
|
||||
import '../../features/organizations/presentation/pages/organizations_page_wrapper.dart';
|
||||
|
||||
/// Page "Plus" avec les fonctions avancées selon le rôle (Menu Principal Extensif)
|
||||
class MorePage extends StatelessWidget {
|
||||
const MorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AuthAuthenticated) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorTokens.background,
|
||||
appBar: const UFAppBar(
|
||||
title: 'PLUS',
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Profil utilisateur
|
||||
_buildUserProfile(state),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Options selon le rôle
|
||||
..._buildRoleBasedOptions(context, state),
|
||||
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Options communes
|
||||
..._buildCommonOptions(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserProfile(AuthAuthenticated state) {
|
||||
return CoreCard(
|
||||
child: Row(
|
||||
children: [
|
||||
MiniAvatar(
|
||||
fallbackText: state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U',
|
||||
size: 40,
|
||||
imageUrl: state.user.avatar,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${state.user.firstName} ${state.user.lastName}',
|
||||
style: AppTypography.actionText,
|
||||
),
|
||||
Text(
|
||||
state.effectiveRole.displayName.toUpperCase(),
|
||||
style: AppTypography.badgeText.copyWith(
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) {
|
||||
final options = <Widget>[];
|
||||
|
||||
// Options Super Admin uniquement
|
||||
if (state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Administration Système'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.people,
|
||||
title: 'Gestion des utilisateurs',
|
||||
subtitle: 'Utilisateurs Keycloak et rôles',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const UserManagementPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.settings,
|
||||
title: 'Paramètres Système',
|
||||
subtitle: 'Configuration globale',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const SystemSettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.backup,
|
||||
title: 'Sauvegarde & Restauration',
|
||||
subtitle: 'Gestion des sauvegardes',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const BackupPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.article,
|
||||
title: 'Logs & Monitoring',
|
||||
subtitle: 'Surveillance et journaux',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const LogsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Options Admin+ (Admin Organisation et Super Admin)
|
||||
if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Administration'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.business,
|
||||
title: 'Gestion des Organisations',
|
||||
subtitle: 'Créer et gérer les organisations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const OrganizationsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Workflow Financier'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.pending_actions,
|
||||
title: 'Approbations en attente',
|
||||
subtitle: 'Valider les transactions financières',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/approvals');
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.account_balance_wallet,
|
||||
title: 'Gestion des Budgets',
|
||||
subtitle: 'Créer et suivre les budgets',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/budgets');
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Communication'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.message,
|
||||
title: 'Messages & Broadcast',
|
||||
subtitle: 'Communiquer avec les membres',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/messages');
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Rapports & Analytics'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.assessment,
|
||||
title: 'Rapports & Analytics',
|
||||
subtitle: 'Statistiques détaillées',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const ReportsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Options Modérateur (Communication limitée)
|
||||
if (state.effectiveRole == UserRole.moderator) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Communication'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.message,
|
||||
title: 'Messages aux membres',
|
||||
subtitle: 'Communiquer avec les membres',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/messages');
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
List<Widget> _buildCommonOptions(BuildContext context) {
|
||||
return [
|
||||
_buildSectionTitle('Général'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.payment,
|
||||
title: 'Cotisations',
|
||||
subtitle: 'Gérer les cotisations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const CotisationsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.how_to_reg,
|
||||
title: 'Demandes d\'adhésion',
|
||||
subtitle: 'Demandes d\'adhésion à une organisation',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const AdhesionsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.volunteer_activism,
|
||||
title: 'Demandes d\'aide',
|
||||
subtitle: 'Solidarité – demandes d\'aide',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const DemandesAidePageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.savings_outlined,
|
||||
title: 'Comptes épargne',
|
||||
subtitle: 'Mutuelle épargne – dépôts (LCB-FT)',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const EpargnePage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24, bottom: 8, left: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
Color? color,
|
||||
}) {
|
||||
final effectiveColor = color ?? AppColors.primaryGreen;
|
||||
|
||||
return CoreCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: effectiveColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.actionText.copyWith(
|
||||
color: color ?? AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: AppTypography.subtitleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textSecondaryLight,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
unionflow/unionflow-mobile-apps/lib/core/network/api_client.dart
Normal file
133
unionflow/unionflow-mobile-apps/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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,418 +1,98 @@
|
||||
/// Gestionnaire de cache multi-niveaux ultra-performant
|
||||
/// Cache mémoire + disque avec TTL adaptatif selon les rôles
|
||||
library dashboard_cache_manager;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
|
||||
/// Gestionnaire de cache intelligent avec stratégie multi-niveaux
|
||||
///
|
||||
/// Niveaux de cache :
|
||||
/// 1. Cache mémoire (ultra-rapide, volatile)
|
||||
/// 2. Cache disque (rapide, persistant)
|
||||
/// 3. Cache réseau (si applicable)
|
||||
///
|
||||
/// Fonctionnalités :
|
||||
/// - TTL adaptatif selon le rôle utilisateur
|
||||
/// - Compression automatique des données volumineuses
|
||||
/// - Invalidation intelligente
|
||||
/// - Métriques de performance
|
||||
/// - Nettoyage automatique
|
||||
/// UnionFlow Mobile - Gestionnaire de Cache Unique (DRY)
|
||||
/// Gère le cache mémoire (L1) et disque (L2) avec SharedPreferences.
|
||||
class DashboardCacheManager {
|
||||
static final DashboardCacheManager _instance = DashboardCacheManager._internal();
|
||||
factory DashboardCacheManager() => _instance;
|
||||
DashboardCacheManager._internal();
|
||||
|
||||
/// Cache mémoire niveau 1 (ultra-rapide)
|
||||
static final Map<String, _CachedData> _memoryCache = {};
|
||||
|
||||
/// Instance SharedPreferences pour le cache disque
|
||||
static final Map<String, dynamic> _memoryCache = {};
|
||||
static final Map<String, DateTime> _cacheTimestamps = {};
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
/// Taille maximale du cache mémoire (en nombre d'entrées)
|
||||
static const int _maxMemoryCacheSize = 1000;
|
||||
|
||||
/// Taille maximale du cache disque (en MB)
|
||||
static const int _maxDiskCacheSizeMB = 50;
|
||||
|
||||
/// TTL par défaut selon les rôles
|
||||
static const Map<UserRole, Duration> _roleTTL = {
|
||||
UserRole.superAdmin: Duration(hours: 2), // Cache plus long pour les admins
|
||||
UserRole.orgAdmin: Duration(hours: 1), // Cache modéré pour les admins org
|
||||
UserRole.moderator: Duration(minutes: 30), // Cache court pour les modérateurs
|
||||
UserRole.activeMember: Duration(minutes: 15), // Cache très court pour les membres
|
||||
UserRole.simpleMember: Duration(minutes: 10), // Cache minimal
|
||||
UserRole.visitor: Duration(minutes: 5), // Cache très court pour les visiteurs
|
||||
};
|
||||
|
||||
/// Compteurs de performance
|
||||
static int _memoryHits = 0;
|
||||
static int _memoryMisses = 0;
|
||||
static int _diskHits = 0;
|
||||
static int _diskMisses = 0;
|
||||
|
||||
/// Timer pour le nettoyage automatique
|
||||
static Timer? _cleanupTimer;
|
||||
static const Duration _defaultExpiry = Duration(minutes: 15);
|
||||
|
||||
/// Initialise le gestionnaire de cache
|
||||
static Future<void> initialize() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Démarrer le nettoyage automatique toutes les 30 minutes
|
||||
_cleanupTimer = Timer.periodic(
|
||||
const Duration(minutes: 30),
|
||||
(_) => _performAutomaticCleanup(),
|
||||
);
|
||||
|
||||
debugPrint('DashboardCacheManager initialisé');
|
||||
debugPrint('📦 DashboardCacheManager Initialisé');
|
||||
}
|
||||
|
||||
/// Dispose le gestionnaire de cache
|
||||
static void dispose() {
|
||||
_cleanupTimer?.cancel();
|
||||
_memoryCache.clear();
|
||||
}
|
||||
|
||||
/// Récupère une donnée du cache avec stratégie multi-niveaux
|
||||
///
|
||||
/// [key] - Clé unique de la donnée
|
||||
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
|
||||
/// [fromDisk] - Autoriser la récupération depuis le disque
|
||||
static Future<T?> get<T>(
|
||||
String key,
|
||||
UserRole userRole, {
|
||||
bool fromDisk = true,
|
||||
}) async {
|
||||
// Niveau 1 : Cache mémoire
|
||||
final memoryData = _getFromMemory<T>(key);
|
||||
if (memoryData != null) {
|
||||
_memoryHits++;
|
||||
return memoryData;
|
||||
}
|
||||
_memoryMisses++;
|
||||
|
||||
// Niveau 2 : Cache disque
|
||||
if (fromDisk && _prefs != null) {
|
||||
final diskData = await _getFromDisk<T>(key, userRole);
|
||||
if (diskData != null) {
|
||||
_diskHits++;
|
||||
// Remettre en cache mémoire pour les prochains accès
|
||||
await _putInMemory(key, diskData, userRole);
|
||||
return diskData;
|
||||
static T? get<T>(String key) {
|
||||
// 1. Check mémoire
|
||||
if (_memoryCache.containsKey(key)) {
|
||||
final ts = _cacheTimestamps[key];
|
||||
if (ts != null && DateTime.now().difference(ts) < _defaultExpiry) {
|
||||
return _memoryCache[key] as T?;
|
||||
}
|
||||
}
|
||||
// 2. Check disque
|
||||
if (_prefs != null) {
|
||||
final jsonStr = _prefs!.getString('cache_$key');
|
||||
if (jsonStr != null) {
|
||||
try {
|
||||
final data = jsonDecode(jsonStr);
|
||||
_memoryCache[key] = data;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
return data as T?;
|
||||
} catch (e, st) {
|
||||
AppLogger.error('DashboardCacheManager.get: décodage JSON échoué pour key=$key', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
_diskMisses++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stocke une donnée dans le cache avec stratégie multi-niveaux
|
||||
///
|
||||
/// [key] - Clé unique de la donnée
|
||||
/// [data] - Donnée à stocker
|
||||
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
|
||||
/// [toDisk] - Sauvegarder sur disque
|
||||
/// [compress] - Compresser les données volumineuses
|
||||
static Future<void> put<T>(
|
||||
String key,
|
||||
T data,
|
||||
UserRole userRole, {
|
||||
bool toDisk = true,
|
||||
bool compress = false,
|
||||
}) async {
|
||||
// Niveau 1 : Cache mémoire
|
||||
await _putInMemory(key, data, userRole);
|
||||
|
||||
// Niveau 2 : Cache disque
|
||||
if (toDisk && _prefs != null) {
|
||||
await _putOnDisk(key, data, userRole, compress: compress);
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalide une entrée du cache
|
||||
static Future<void> invalidate(String key) async {
|
||||
// Supprimer du cache mémoire
|
||||
_memoryCache.remove(key);
|
||||
|
||||
// Supprimer du cache disque
|
||||
static Future<void> set<T>(String key, T value) async {
|
||||
_memoryCache[key] = value;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
if (_prefs != null) {
|
||||
await _prefs!.remove('cache_$key');
|
||||
await _prefs!.remove('cache_meta_$key');
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalide toutes les entrées d'un préfixe
|
||||
static Future<void> invalidatePrefix(String prefix) async {
|
||||
// Cache mémoire
|
||||
final keysToRemove = _memoryCache.keys
|
||||
.where((key) => key.startsWith(prefix))
|
||||
.toList();
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_memoryCache.remove(key);
|
||||
}
|
||||
|
||||
// Cache disque
|
||||
if (_prefs != null) {
|
||||
final allKeys = _prefs!.getKeys();
|
||||
final diskKeysToRemove = allKeys
|
||||
.where((key) => key.startsWith('cache_$prefix'))
|
||||
.toList();
|
||||
|
||||
for (final key in diskKeysToRemove) {
|
||||
await _prefs!.remove(key);
|
||||
try {
|
||||
await _prefs!.setString('cache_$key', jsonEncode(value));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('DashboardCacheManager.set: écriture disque échouée pour key=$key', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vide complètement le cache
|
||||
static Future<void> invalidateForRole(UserRole role) async {
|
||||
_memoryCache.removeWhere((key, _) => key.contains(role.name));
|
||||
_cacheTimestamps.removeWhere((key, _) => key.contains(role.name));
|
||||
if (_prefs != null) {
|
||||
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_') && k.contains(role.name));
|
||||
for (final k in keys) {
|
||||
await _prefs!.remove(k);
|
||||
}
|
||||
}
|
||||
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
_memoryCache.clear();
|
||||
|
||||
_cacheTimestamps.clear();
|
||||
if (_prefs != null) {
|
||||
final allKeys = _prefs!.getKeys();
|
||||
final cacheKeys = allKeys.where((key) => key.startsWith('cache_')).toList();
|
||||
|
||||
for (final key in cacheKeys) {
|
||||
await _prefs!.remove(key);
|
||||
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_'));
|
||||
for (final k in keys) {
|
||||
await _prefs!.remove(k);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Cache complètement vidé');
|
||||
debugPrint('🧹 Cache vidé globalement');
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du cache
|
||||
static Map<String, dynamic> getStats() {
|
||||
final totalMemoryRequests = _memoryHits + _memoryMisses;
|
||||
final totalDiskRequests = _diskHits + _diskMisses;
|
||||
|
||||
final memoryHitRate = totalMemoryRequests > 0
|
||||
? (_memoryHits / totalMemoryRequests * 100)
|
||||
: 0.0;
|
||||
|
||||
final diskHitRate = totalDiskRequests > 0
|
||||
? (_diskHits / totalDiskRequests * 100)
|
||||
: 0.0;
|
||||
|
||||
/// Délégation instance pour l’injection de dépendances
|
||||
Future<void> setKey<T>(String key, T value) async => set<T>(key, value);
|
||||
|
||||
/// Délégation instance pour l’injection de dépendances
|
||||
T? getKey<T>(String key) => get<T>(key);
|
||||
|
||||
/// Statistiques du cache (entrées, etc.)
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
final latest = _cacheTimestamps.isEmpty ? null : _cacheTimestamps.values.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
return {
|
||||
'memoryCache': {
|
||||
'hits': _memoryHits,
|
||||
'misses': _memoryMisses,
|
||||
'hitRate': memoryHitRate.toStringAsFixed(2),
|
||||
'size': _memoryCache.length,
|
||||
'maxSize': _maxMemoryCacheSize,
|
||||
},
|
||||
'diskCache': {
|
||||
'hits': _diskHits,
|
||||
'misses': _diskMisses,
|
||||
'hitRate': diskHitRate.toStringAsFixed(2),
|
||||
'maxSizeMB': _maxDiskCacheSizeMB,
|
||||
},
|
||||
'entries': _memoryCache.length,
|
||||
'timestamp': latest?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Effectue un nettoyage manuel du cache
|
||||
static Future<void> cleanup() async {
|
||||
await _performAutomaticCleanup();
|
||||
}
|
||||
|
||||
// === MÉTHODES PRIVÉES ===
|
||||
|
||||
/// Récupère une donnée du cache mémoire
|
||||
static T? _getFromMemory<T>(String key) {
|
||||
final cached = _memoryCache[key];
|
||||
if (cached == null) return null;
|
||||
|
||||
// Vérifier l'expiration
|
||||
if (cached.expiresAt.isBefore(DateTime.now())) {
|
||||
_memoryCache.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data as T?;
|
||||
}
|
||||
|
||||
/// Stocke une donnée dans le cache mémoire
|
||||
static Future<void> _putInMemory<T>(String key, T data, UserRole userRole) async {
|
||||
// Vérifier la taille du cache et nettoyer si nécessaire
|
||||
if (_memoryCache.length >= _maxMemoryCacheSize) {
|
||||
await _cleanOldestMemoryEntries();
|
||||
}
|
||||
|
||||
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
|
||||
|
||||
_memoryCache[key] = _CachedData(
|
||||
data: data,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Récupère une donnée du cache disque
|
||||
static Future<T?> _getFromDisk<T>(String key, UserRole userRole) async {
|
||||
if (_prefs == null) return null;
|
||||
|
||||
// Récupérer les métadonnées
|
||||
final metaJson = _prefs!.getString('cache_meta_$key');
|
||||
if (metaJson == null) return null;
|
||||
|
||||
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
|
||||
final expiresAt = DateTime.parse(meta['expiresAt']);
|
||||
|
||||
// Vérifier l'expiration
|
||||
if (expiresAt.isBefore(DateTime.now())) {
|
||||
await _prefs!.remove('cache_$key');
|
||||
await _prefs!.remove('cache_meta_$key');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Récupérer les données
|
||||
final dataJson = _prefs!.getString('cache_$key');
|
||||
if (dataJson == null) return null;
|
||||
|
||||
try {
|
||||
final data = jsonDecode(dataJson);
|
||||
return data as T;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de désérialisation du cache: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stocke une donnée sur le cache disque
|
||||
static Future<void> _putOnDisk<T>(
|
||||
String key,
|
||||
T data,
|
||||
UserRole userRole, {
|
||||
bool compress = false,
|
||||
}) async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
|
||||
final expiresAt = DateTime.now().add(ttl);
|
||||
|
||||
// Sérialiser les données
|
||||
final dataJson = jsonEncode(data);
|
||||
|
||||
// Métadonnées
|
||||
final meta = {
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
'userRole': userRole.name,
|
||||
'compressed': compress,
|
||||
};
|
||||
|
||||
// Sauvegarder
|
||||
await _prefs!.setString('cache_$key', dataJson);
|
||||
await _prefs!.setString('cache_meta_$key', jsonEncode(meta));
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de sérialisation du cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoie les entrées les plus anciennes du cache mémoire
|
||||
static Future<void> _cleanOldestMemoryEntries() async {
|
||||
if (_memoryCache.isEmpty) return;
|
||||
|
||||
// Trier par date de création et supprimer les 10% les plus anciennes
|
||||
final entries = _memoryCache.entries.toList();
|
||||
entries.sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
|
||||
|
||||
final toRemove = (entries.length * 0.1).ceil();
|
||||
for (int i = 0; i < toRemove && i < entries.length; i++) {
|
||||
_memoryCache.remove(entries[i].key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Effectue un nettoyage automatique
|
||||
static Future<void> _performAutomaticCleanup() async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Nettoyer le cache mémoire expiré
|
||||
_memoryCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
|
||||
|
||||
// Nettoyer le cache disque expiré
|
||||
if (_prefs != null) {
|
||||
final allKeys = _prefs!.getKeys();
|
||||
final metaKeys = allKeys.where((key) => key.startsWith('cache_meta_')).toList();
|
||||
|
||||
for (final metaKey in metaKeys) {
|
||||
final metaJson = _prefs!.getString(metaKey);
|
||||
if (metaJson != null) {
|
||||
try {
|
||||
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
|
||||
final expiresAt = DateTime.parse(meta['expiresAt']);
|
||||
|
||||
if (expiresAt.isBefore(now)) {
|
||||
final dataKey = metaKey.replaceFirst('cache_meta_', 'cache_');
|
||||
await _prefs!.remove(dataKey);
|
||||
await _prefs!.remove(metaKey);
|
||||
}
|
||||
} catch (e) {
|
||||
// Supprimer les métadonnées corrompues
|
||||
await _prefs!.remove(metaKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Nettoyage automatique du cache effectué');
|
||||
}
|
||||
|
||||
/// Invalide le cache pour un rôle spécifique
|
||||
static Future<void> invalidateForRole(UserRole role) async {
|
||||
debugPrint('🗑️ Invalidation du cache pour le rôle: ${role.displayName}');
|
||||
|
||||
// Invalider le cache mémoire pour ce rôle
|
||||
final keysToRemove = <String>[];
|
||||
for (final key in _memoryCache.keys) {
|
||||
if (key.contains(role.name)) {
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_memoryCache.remove(key);
|
||||
}
|
||||
|
||||
// Invalider le cache disque pour ce rôle
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
if (_prefs != null) {
|
||||
final keys = _prefs!.getKeys();
|
||||
final diskKeysToRemove = <String>[];
|
||||
|
||||
for (final key in keys) {
|
||||
if (key.startsWith('cache_') && key.contains(role.name)) {
|
||||
diskKeysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in diskKeysToRemove) {
|
||||
await _prefs!.remove(key);
|
||||
// Supprimer aussi les métadonnées associées
|
||||
final metaKey = key.replaceFirst('cache_', 'cache_meta_');
|
||||
await _prefs!.remove(metaKey);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ Cache invalidé pour le rôle: ${role.displayName}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe pour les données mises en cache
|
||||
class _CachedData {
|
||||
final dynamic data;
|
||||
final DateTime expiresAt;
|
||||
final DateTime createdAt;
|
||||
|
||||
_CachedData({
|
||||
required this.data,
|
||||
required this.expiresAt,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/// Storage for pending operations that failed due to network issues
|
||||
library pending_operations_store;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/logger.dart' show AppLogger;
|
||||
|
||||
/// Store for persisting failed operations to retry later
|
||||
@singleton
|
||||
class PendingOperationsStore {
|
||||
static const String _keyPendingOperations = 'pending_operations';
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
PendingOperationsStore(this._preferences);
|
||||
|
||||
/// Add a pending operation to the store
|
||||
Future<void> addPendingOperation({
|
||||
required String operationType,
|
||||
required String endpoint,
|
||||
required Map<String, dynamic> data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
|
||||
final newOperation = {
|
||||
'id': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'operationType': operationType,
|
||||
'endpoint': endpoint,
|
||||
'data': data,
|
||||
'headers': headers ?? {},
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'retryCount': 0,
|
||||
};
|
||||
|
||||
operations.add(newOperation);
|
||||
|
||||
await _saveOperations(operations);
|
||||
|
||||
AppLogger.info('Pending operation added: $operationType on $endpoint');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to add pending operation', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pending operations
|
||||
Future<List<Map<String, dynamic>>> getPendingOperations() async {
|
||||
try {
|
||||
final json = _preferences.getString(_keyPendingOperations);
|
||||
|
||||
if (json == null || json.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get pending operations', error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a pending operation by ID
|
||||
Future<void> removePendingOperation(String id) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
operations.removeWhere((op) => op['id'] == id);
|
||||
await _saveOperations(operations);
|
||||
|
||||
AppLogger.info('Pending operation removed: $id');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to remove pending operation', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update retry count for an operation
|
||||
Future<void> incrementRetryCount(String id) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
|
||||
final index = operations.indexWhere((op) => op['id'] == id);
|
||||
if (index != -1) {
|
||||
operations[index]['retryCount'] = (operations[index]['retryCount'] ?? 0) + 1;
|
||||
operations[index]['lastRetryTimestamp'] = DateTime.now().toIso8601String();
|
||||
await _saveOperations(operations);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to increment retry count', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending operations
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _preferences.remove(_keyPendingOperations);
|
||||
AppLogger.info('All pending operations cleared');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to clear pending operations', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove operations older than a certain duration
|
||||
Future<void> removeOldOperations({Duration maxAge = const Duration(days: 7)}) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
final now = DateTime.now();
|
||||
|
||||
final filtered = operations.where((op) {
|
||||
final timestamp = DateTime.parse(op['timestamp'] as String);
|
||||
return now.difference(timestamp) < maxAge;
|
||||
}).toList();
|
||||
|
||||
if (filtered.length != operations.length) {
|
||||
await _saveOperations(filtered);
|
||||
AppLogger.info('Removed ${operations.length - filtered.length} old operations');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to remove old operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get operations by type
|
||||
Future<List<Map<String, dynamic>>> getOperationsByType(String operationType) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
return operations.where((op) => op['operationType'] == operationType).toList();
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get operations by type', error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of pending operations
|
||||
Future<int> getCount() async {
|
||||
final operations = await getPendingOperations();
|
||||
return operations.length;
|
||||
}
|
||||
|
||||
/// Save operations to storage
|
||||
Future<void> _saveOperations(List<Map<String, dynamic>> operations) async {
|
||||
try {
|
||||
final json = jsonEncode(operations);
|
||||
await _preferences.setString(_keyPendingOperations, json);
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to save operations', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,14 @@ class AppLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback optionnel pour envoyer les erreurs au monitoring (Sentry / Firebase Crashlytics).
|
||||
/// À enregistrer au démarrage de l'app quand le SDK est intégré.
|
||||
static void Function(String message, dynamic error, StackTrace? stackTrace, {bool isFatal})? onMonitoringReport;
|
||||
|
||||
/// Callback optionnel pour envoyer les événements analytics (Firebase Analytics / Mixpanel).
|
||||
/// À enregistrer au démarrage de l'app quand le SDK est intégré.
|
||||
static void Function(String action, Map<String, dynamic>? data)? onAnalyticsEvent;
|
||||
|
||||
/// Envoyer les erreurs à un service de monitoring
|
||||
static void _sendToMonitoring(
|
||||
String message,
|
||||
@@ -239,23 +247,38 @@ class AppLogger {
|
||||
StackTrace? stackTrace, {
|
||||
bool isFatal = false,
|
||||
}) {
|
||||
// Stub — implémenter avec Sentry ou Firebase Crashlytics quand intégré
|
||||
// Exemple avec Sentry:
|
||||
// Sentry.captureException(
|
||||
// error,
|
||||
// stackTrace: stackTrace,
|
||||
// hint: Hint.withMap({'message': message}),
|
||||
// );
|
||||
if (onMonitoringReport != null) {
|
||||
try {
|
||||
onMonitoringReport!(message, error, stackTrace, isFatal: isFatal);
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: échec envoi monitoring: $e');
|
||||
debugPrint('$st');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kDebugMode && (error != null || stackTrace != null)) {
|
||||
debugPrint('AppLogger: monitoring non configuré (enregistrer onMonitoringReport pour Sentry/Crashlytics)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer les événements à un service d'analytics
|
||||
static void _sendToAnalytics(String action, Map<String, dynamic>? data) {
|
||||
// Stub — implémenter avec Firebase Analytics ou Mixpanel quand intégré
|
||||
// Exemple avec Firebase Analytics:
|
||||
// FirebaseAnalytics.instance.logEvent(
|
||||
// name: action,
|
||||
// parameters: data,
|
||||
// );
|
||||
if (onAnalyticsEvent != null) {
|
||||
try {
|
||||
onAnalyticsEvent!(action, data);
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: échec envoi analytics: $e');
|
||||
debugPrint('$st');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: analytics non configuré (enregistrer onAnalyticsEvent pour Firebase/Mixpanel)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Divider pour séparer visuellement les logs
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/// Core validation utilities for form fields
|
||||
library validators;
|
||||
|
||||
/// Validator function type
|
||||
typedef FieldValidator = String? Function(String?)?;
|
||||
|
||||
/// Compose multiple validators
|
||||
FieldValidator composeValidators(List<FieldValidator> validators) {
|
||||
return (String? value) {
|
||||
for (final validator in validators) {
|
||||
final result = validator?.call(value);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Common validators
|
||||
class Validators {
|
||||
Validators._(); // Prevent instantiation
|
||||
|
||||
/// Required field validator
|
||||
static FieldValidator required({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return message ?? 'Ce champ est requis';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Minimum length validator
|
||||
static FieldValidator minLength(int length, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != null && value.trim().length < length) {
|
||||
return message ?? 'Minimum $length caractères requis';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Maximum length validator
|
||||
static FieldValidator maxLength(int length, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != null && value.length > length) {
|
||||
return message ?? 'Maximum $length caractères autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Email validator
|
||||
static FieldValidator email({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Use required() separately if needed
|
||||
}
|
||||
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Adresse email invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Numeric validator
|
||||
static FieldValidator numeric({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Use required() separately if needed
|
||||
}
|
||||
|
||||
if (double.tryParse(value.trim()) == null) {
|
||||
return message ?? 'Veuillez entrer un nombre valide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Minimum value validator (for numeric fields)
|
||||
static FieldValidator minValue(double min, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue < min) {
|
||||
return message ?? 'La valeur doit être au moins $min';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Maximum value validator (for numeric fields)
|
||||
static FieldValidator maxValue(double max, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue > max) {
|
||||
return message ?? 'La valeur doit être au maximum $max';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Range validator (for numeric fields)
|
||||
static FieldValidator range(double min, double max, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue < min || numValue > max) {
|
||||
return message ?? 'La valeur doit être entre $min et $max';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Phone number validator (simple version)
|
||||
static FieldValidator phone({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Allow digits, spaces, +, -, ()
|
||||
final phoneRegex = RegExp(r'^[\d\s\+\-\(\)]+$');
|
||||
if (!phoneRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Numéro de téléphone invalide';
|
||||
}
|
||||
|
||||
// Check minimum length (at least 8 digits)
|
||||
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.length < 8) {
|
||||
return message ?? 'Numéro de téléphone trop court';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// URL validator
|
||||
static FieldValidator url({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(value.trim());
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return message ?? 'URL invalide';
|
||||
}
|
||||
} catch (e) {
|
||||
return message ?? 'URL invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Pattern/Regex validator
|
||||
static FieldValidator pattern(RegExp regex, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!regex.hasMatch(value.trim())) {
|
||||
return message ?? 'Format invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Match validator (confirm password, etc.)
|
||||
static FieldValidator match(String otherValue, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != otherValue) {
|
||||
return message ?? 'Les valeurs ne correspondent pas';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Custom validator
|
||||
static FieldValidator custom(bool Function(String?) test, {String? message}) {
|
||||
return (String? value) {
|
||||
if (!test(value)) {
|
||||
return message ?? 'Valeur invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Alphanumeric validator
|
||||
static FieldValidator alphanumeric({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final alphanumericRegex = RegExp(r'^[a-zA-Z0-9]+$');
|
||||
if (!alphanumericRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Seuls les caractères alphanumériques sont autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// No whitespace validator
|
||||
static FieldValidator noWhitespace({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null) return null;
|
||||
|
||||
if (value.contains(' ')) {
|
||||
return message ?? 'Les espaces ne sont pas autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Finance-specific validators
|
||||
class FinanceValidators {
|
||||
FinanceValidators._();
|
||||
|
||||
/// Amount validator (positive number with max 2 decimals)
|
||||
static FieldValidator amount({
|
||||
double? min,
|
||||
double? max,
|
||||
String? message,
|
||||
}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if numeric
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
// Check if positive
|
||||
if (numValue <= 0) {
|
||||
return 'Le montant doit être positif';
|
||||
}
|
||||
|
||||
// Check min/max
|
||||
if (min != null && numValue < min) {
|
||||
return message ?? 'Le montant minimum est $min';
|
||||
}
|
||||
if (max != null && numValue > max) {
|
||||
return message ?? 'Le montant maximum est $max';
|
||||
}
|
||||
|
||||
// Check max 2 decimals
|
||||
final parts = value.trim().split('.');
|
||||
if (parts.length > 1 && parts[1].length > 2) {
|
||||
return 'Maximum 2 décimales autorisées';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Budget line name validator
|
||||
static FieldValidator budgetLineName() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'Le nom de la ligne budgétaire est requis'),
|
||||
Validators.minLength(3, message: 'Minimum 3 caractères'),
|
||||
Validators.maxLength(100, message: 'Maximum 100 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Budget description validator
|
||||
static FieldValidator budgetDescription({bool required = false}) {
|
||||
return composeValidators([
|
||||
if (required)
|
||||
Validators.required(message: 'La description est requise'),
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Rejection reason validator
|
||||
static FieldValidator rejectionReason() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'La raison du rejet est requise'),
|
||||
Validators.minLength(10, message: 'Veuillez fournir une raison plus détaillée (min 10 caractères)'),
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Approval comment validator (optional but with constraints if provided)
|
||||
static FieldValidator approvalComment() {
|
||||
return composeValidators([
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Budget name validator
|
||||
static FieldValidator budgetName() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'Le nom du budget est requis'),
|
||||
Validators.minLength(3, message: 'Minimum 3 caractères'),
|
||||
Validators.maxLength(200, message: 'Maximum 200 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Fiscal year validator
|
||||
static FieldValidator fiscalYear() {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'L\'année est requise';
|
||||
}
|
||||
|
||||
final year = int.tryParse(value.trim());
|
||||
if (year == null) {
|
||||
return 'Année invalide';
|
||||
}
|
||||
|
||||
final currentYear = DateTime.now().year;
|
||||
if (year < currentYear - 5 || year > currentYear + 10) {
|
||||
return 'L\'année doit être entre ${currentYear - 5} et ${currentYear + 10}';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// WebSocket core exports
|
||||
library websocket;
|
||||
|
||||
export 'websocket_service.dart';
|
||||
@@ -0,0 +1,349 @@
|
||||
/// Service WebSocket pour temps réel (Kafka events)
|
||||
library websocket_service;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
|
||||
import '../config/environment.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Events WebSocket typés
|
||||
abstract class WebSocketEvent {
|
||||
final String eventType;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
WebSocketEvent({
|
||||
required this.eventType,
|
||||
required this.timestamp,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory WebSocketEvent.fromJson(Map<String, dynamic> json) {
|
||||
final eventType = json['eventType'] as String;
|
||||
final timestamp = DateTime.parse(json['timestamp'] as String);
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
|
||||
switch (eventType) {
|
||||
case 'APPROVAL_PENDING':
|
||||
case 'APPROVAL_APPROVED':
|
||||
case 'APPROVAL_REJECTED':
|
||||
return FinanceApprovalEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'DASHBOARD_STATS_UPDATED':
|
||||
case 'KPI_UPDATED':
|
||||
return DashboardStatsEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'USER_NOTIFICATION':
|
||||
case 'BROADCAST_NOTIFICATION':
|
||||
return NotificationEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
userId: json['userId'] as String?,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'MEMBER_CREATED':
|
||||
case 'MEMBER_UPDATED':
|
||||
return MemberEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'CONTRIBUTION_PAID':
|
||||
return ContributionEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
default:
|
||||
return GenericEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FinanceApprovalEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
FinanceApprovalEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class DashboardStatsEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
DashboardStatsEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class NotificationEvent extends WebSocketEvent {
|
||||
final String? userId;
|
||||
final String? organizationId;
|
||||
|
||||
NotificationEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.userId,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class MemberEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
MemberEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class ContributionEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
ContributionEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class GenericEvent extends WebSocketEvent {
|
||||
GenericEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service WebSocket pour recevoir les events temps réel du backend
|
||||
@singleton
|
||||
class WebSocketService {
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
final StreamController<WebSocketEvent> _eventController = StreamController.broadcast();
|
||||
final StreamController<bool> _connectionStatusController = StreamController.broadcast();
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _shouldReconnect = true;
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
/// Stream des events WebSocket typés
|
||||
Stream<WebSocketEvent> get eventStream => _eventController.stream;
|
||||
|
||||
/// Stream du statut de connexion
|
||||
Stream<bool> get connectionStatusStream => _connectionStatusController.stream;
|
||||
|
||||
/// Statut de connexion actuel
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Connexion au WebSocket
|
||||
void connect() {
|
||||
if (_isConnected || _channel != null) {
|
||||
AppLogger.info('WebSocket déjà connecté');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final wsUrl = _buildWebSocketUrl();
|
||||
AppLogger.info('Connexion WebSocket à $wsUrl...');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
_channel!.stream.listen(
|
||||
_onMessage,
|
||||
onError: _onError,
|
||||
onDone: _onDone,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
_connectionStatusController.add(true);
|
||||
|
||||
// Heartbeat toutes les 30 secondes
|
||||
_startHeartbeat();
|
||||
|
||||
AppLogger.info('✅ WebSocket connecté avec succès');
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur connexion WebSocket', error: e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnexion du WebSocket
|
||||
void disconnect() {
|
||||
AppLogger.info('Déconnexion WebSocket...');
|
||||
_shouldReconnect = false;
|
||||
_stopHeartbeat();
|
||||
_stopReconnectTimer();
|
||||
|
||||
_channel?.sink.close(status.goingAway);
|
||||
_channel = null;
|
||||
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
}
|
||||
|
||||
/// Dispose des ressources
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_eventController.close();
|
||||
_connectionStatusController.close();
|
||||
}
|
||||
|
||||
/// Construit l'URL WebSocket depuis l'URL backend
|
||||
String _buildWebSocketUrl() {
|
||||
var baseUrl = AppConfig.apiBaseUrl;
|
||||
|
||||
// Remplacer http/https par ws/wss
|
||||
if (baseUrl.startsWith('https://')) {
|
||||
baseUrl = baseUrl.replaceFirst('https://', 'wss://');
|
||||
} else if (baseUrl.startsWith('http://')) {
|
||||
baseUrl = baseUrl.replaceFirst('http://', 'ws://');
|
||||
}
|
||||
|
||||
return '$baseUrl/ws/dashboard';
|
||||
}
|
||||
|
||||
/// Gestion des messages reçus
|
||||
void _onMessage(dynamic message) {
|
||||
try {
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket message reçu: $message');
|
||||
}
|
||||
|
||||
final json = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
final type = json['type'] as String?;
|
||||
|
||||
// Gérer les messages système
|
||||
if (type == 'connected') {
|
||||
AppLogger.info('🔗 WebSocket: ${json['data']['message']}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'pong') {
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket heartbeat pong reçu');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'ack') {
|
||||
return; // Accusé de réception, ignoré
|
||||
}
|
||||
|
||||
// Event métier (Kafka)
|
||||
if (json.containsKey('eventType')) {
|
||||
final event = WebSocketEvent.fromJson(json);
|
||||
_eventController.add(event);
|
||||
AppLogger.info('📨 Event reçu: ${event.eventType}');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur parsing message WebSocket', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion des erreurs
|
||||
void _onError(dynamic error) {
|
||||
AppLogger.error('WebSocket error', error: error);
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Gestion de la fermeture de connexion
|
||||
void _onDone() {
|
||||
AppLogger.info('WebSocket connexion fermée');
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
_stopHeartbeat();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Planifier une reconnexion avec backoff exponentiel
|
||||
void _scheduleReconnect() {
|
||||
if (!_shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_stopReconnectTimer();
|
||||
|
||||
// Backoff exponentiel : 2^attempts secondes (max 60s)
|
||||
final delaySeconds = (2 << _reconnectAttempts).clamp(1, 60);
|
||||
_reconnectAttempts++;
|
||||
|
||||
AppLogger.info('⏳ Reconnexion WebSocket dans ${delaySeconds}s (tentative $_reconnectAttempts)');
|
||||
|
||||
_reconnectTimer = Timer(Duration(seconds: delaySeconds), () {
|
||||
AppLogger.info('🔄 Tentative de reconnexion WebSocket...');
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrêter le timer de reconnexion
|
||||
void _stopReconnectTimer() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
/// Démarrer le heartbeat (ping toutes les 30s)
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({'type': 'ping'}));
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket heartbeat ping envoyé');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur envoi heartbeat', error: e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrêter le heartbeat
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user