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

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

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

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

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

- Nettoyage: fichiers temporaires supprimés

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

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

View File

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

View File

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

View File

@@ -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)' : ''}';
}

View File

@@ -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)' : ''}';
}

View File

@@ -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(),
),
],
);
}

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/environment.dart';
import '../di/injection.dart';
import '../error/error_handler.dart';
import '../utils/logger.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../features/authentication/data/datasources/keycloak_auth_service.dart';
/// Client réseau unifié basé sur Dio (Version DRY & Minimaliste).
@lazySingleton
class ApiClient {
late final Dio _dio;
static const FlutterSecureStorage _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
ApiClient() {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
// Intercepteur de Log (Uniquement en Dev)
if (AppConfig.enableLogging) {
_dio.interceptors.add(LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
logPrint: (obj) => print('🌐 [API] $obj'),
));
}
// Intercepteur de Token & Refresh automatique
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Utilise la clé 'kc_access' synchronisée avec KeycloakAuthService
final token = await _storage.read(key: 'kc_access');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (DioException e, handler) async {
// Évite une boucle infinie si le retry échoue aussi avec 401
final isRetry = e.requestOptions.extra['custom_retry'] == true;
if (e.response?.statusCode == 401 && !isRetry) {
final responseBody = e.response?.data;
debugPrint('🔑 [API] 401 Detected. Body: $responseBody. Attempting token refresh...');
final refreshed = await _refreshToken();
if (refreshed) {
final token = await _storage.read(key: 'kc_access');
if (token != null) {
// Marque la requête comme étant un retry
final options = e.requestOptions;
options.extra['custom_retry'] = true;
options.headers['Authorization'] = 'Bearer $token';
try {
debugPrint('🔄 [API] Retrying request: ${options.path}');
final response = await _dio.fetch(options);
return handler.resolve(response);
} on DioException catch (retryError) {
final retryBody = retryError.response?.data;
debugPrint('🚨 [API] Retry failed with status: ${retryError.response?.statusCode}. Body: $retryBody');
if (retryError.response?.statusCode == 401) {
debugPrint('🚪 [API] Persistent 401. Force Logout.');
_forceLogout();
}
return handler.next(retryError);
} catch (retryError) {
debugPrint('🚨 [API] Retry critical error: $retryError');
return handler.next(e);
}
}
} else {
debugPrint('🚪 [API] Refresh failed. Force Logout.');
_forceLogout();
}
}
return handler.next(e);
},
),
);
}
void _forceLogout() {
try {
final authBloc = getIt<AuthBloc>();
authBloc.add(const AuthLogoutRequested());
} catch (e, st) {
AppLogger.error(
'ApiClient: force logout failed - ${ErrorHandler.getErrorMessage(e)}',
error: e,
stackTrace: st,
);
}
}
Future<bool> _refreshToken() async {
try {
final authService = getIt<KeycloakAuthService>();
final newToken = await authService.refreshToken();
return newToken != null;
} catch (e, st) {
AppLogger.error(
'ApiClient: refresh token failed - ${ErrorHandler.getErrorMessage(e)}',
error: e,
stackTrace: st,
);
return false;
}
}
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? queryParameters, Options? options}) => _dio.get<T>(path, queryParameters: queryParameters, options: options);
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.post<T>(path, data: data, queryParameters: queryParameters, options: options);
Future<Response<T>> put<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.put<T>(path, data: data, queryParameters: queryParameters, options: options);
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.delete<T>(path, data: data, queryParameters: queryParameters, options: options);
}

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
/// Offline-first manager for connectivity monitoring and operation queueing
library offline_manager;
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:injectable/injectable.dart';
import '../storage/pending_operations_store.dart';
import '../utils/logger.dart' show AppLogger;
/// Status of network connectivity
enum ConnectivityStatus {
online,
offline,
unknown,
}
/// Offline manager that monitors connectivity and manages offline operations
@singleton
class OfflineManager {
final Connectivity _connectivity;
final PendingOperationsStore _operationsStore;
ConnectivityStatus _currentStatus = ConnectivityStatus.unknown;
final _statusController = StreamController<ConnectivityStatus>.broadcast();
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
OfflineManager(
this._connectivity,
this._operationsStore,
) {
_initConnectivityMonitoring();
}
/// Current connectivity status
ConnectivityStatus get currentStatus => _currentStatus;
/// Stream of connectivity status changes
Stream<ConnectivityStatus> get statusStream => _statusController.stream;
/// Check if device is currently online
Future<bool> get isOnline async {
final result = await _connectivity.checkConnectivity();
return result.any((r) => r != ConnectivityResult.none);
}
/// Initialize connectivity monitoring
void _initConnectivityMonitoring() {
// Check initial connectivity
_connectivity.checkConnectivity().then((result) {
_updateStatus(result);
});
// Listen for connectivity changes
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
_updateStatus,
onError: (error) {
AppLogger.error('Connectivity monitoring error', error: error);
_updateStatus([ConnectivityResult.none]);
},
);
}
/// Update connectivity status
void _updateStatus(List<ConnectivityResult> results) {
final isConnected = results.any((r) => r != ConnectivityResult.none);
final newStatus = isConnected
? ConnectivityStatus.online
: ConnectivityStatus.offline;
if (newStatus != _currentStatus) {
final previousStatus = _currentStatus;
_currentStatus = newStatus;
_statusController.add(newStatus);
AppLogger.info('Connectivity changed: $previousStatus$newStatus');
// When back online, process pending operations
if (newStatus == ConnectivityStatus.online &&
previousStatus == ConnectivityStatus.offline) {
_processPendingOperations();
}
}
}
/// Queue an operation for later retry when offline
Future<void> queueOperation({
required String operationType,
required String endpoint,
required Map<String, dynamic> data,
Map<String, String>? headers,
}) async {
try {
await _operationsStore.addPendingOperation(
operationType: operationType,
endpoint: endpoint,
data: data,
headers: headers,
);
AppLogger.info('Operation queued: $operationType on $endpoint');
} catch (e) {
AppLogger.error('Failed to queue operation', error: e);
}
}
/// Process all pending operations when back online
Future<void> _processPendingOperations() async {
AppLogger.info('Processing pending operations...');
try {
final operations = await _operationsStore.getPendingOperations();
if (operations.isEmpty) {
AppLogger.info('No pending operations to process');
return;
}
AppLogger.info('Found ${operations.length} pending operations');
// Process operations one by one
for (final operation in operations) {
try {
// Note: Actual retry logic is delegated to the calling code
// This manager only provides the queuing mechanism
AppLogger.info('Pending operation ready for retry: ${operation['operationType']}');
} catch (e) {
AppLogger.error('Error processing pending operation', error: e);
}
}
} catch (e) {
AppLogger.error('Failed to process pending operations', error: e);
}
}
/// Manually trigger processing of pending operations
Future<void> retryPendingOperations() async {
if (_currentStatus == ConnectivityStatus.online) {
await _processPendingOperations();
} else {
AppLogger.warning('Cannot retry pending operations while offline');
}
}
/// Clear all pending operations
Future<void> clearPendingOperations() async {
try {
await _operationsStore.clearAll();
AppLogger.info('Pending operations cleared');
} catch (e) {
AppLogger.error('Failed to clear pending operations', error: e);
}
}
/// Get count of pending operations
Future<int> getPendingOperationsCount() async {
try {
final operations = await _operationsStore.getPendingOperations();
return operations.length;
} catch (e) {
AppLogger.error('Failed to get pending operations count', error: e);
return 0;
}
}
/// Dispose resources
void dispose() {
_connectivitySubscription?.cancel();
_statusController.close();
}
}

View File

@@ -0,0 +1,160 @@
/// Retry policy with exponential backoff for network requests
library retry_policy;
import 'dart:async';
import 'dart:io';
import 'dart:math';
/// Configuration for retry behavior
class RetryConfig {
/// Maximum number of retry attempts
final int maxAttempts;
/// Initial delay before first retry (milliseconds)
final int initialDelayMs;
/// Maximum delay between retries (milliseconds)
final int maxDelayMs;
/// Multiplier for exponential backoff
final double backoffMultiplier;
/// Whether to add jitter to retry delays
final bool useJitter;
const RetryConfig({
this.maxAttempts = 3,
this.initialDelayMs = 1000,
this.maxDelayMs = 30000,
this.backoffMultiplier = 2.0,
this.useJitter = true,
});
/// Default configuration for standard API calls
static const standard = RetryConfig();
/// Configuration for critical operations
static const critical = RetryConfig(
maxAttempts: 5,
initialDelayMs: 500,
maxDelayMs: 60000,
);
/// Configuration for background sync
static const backgroundSync = RetryConfig(
maxAttempts: 10,
initialDelayMs: 2000,
maxDelayMs: 120000,
);
}
/// Retry policy implementation with exponential backoff
class RetryPolicy {
final RetryConfig config;
final Random _random = Random();
RetryPolicy({RetryConfig? config}) : config = config ?? RetryConfig.standard;
/// Executes an operation with retry logic
///
/// [operation]: The async operation to execute
/// [shouldRetry]: Optional function to determine if error is retryable
/// [onRetry]: Optional callback when retry attempt is made
///
/// Returns the result of the operation
/// Throws the last error if all retries fail
Future<T> execute<T>({
required Future<T> Function() operation,
bool Function(dynamic error)? shouldRetry,
void Function(int attempt, dynamic error, Duration delay)? onRetry,
}) async {
int attempt = 0;
dynamic lastError;
while (attempt < config.maxAttempts) {
try {
return await operation();
} catch (error) {
lastError = error;
attempt++;
// Check if we should retry this error
final retryable = shouldRetry?.call(error) ?? _isRetryableError(error);
if (!retryable || attempt >= config.maxAttempts) {
throw error;
}
// Calculate delay with exponential backoff
final delay = _calculateDelay(attempt);
// Notify about retry
onRetry?.call(attempt, error, delay);
// Wait before next attempt
await Future.delayed(delay);
}
}
// Should never reach here, but throw last error just in case
throw lastError!;
}
/// Calculates delay for given attempt number
Duration _calculateDelay(int attempt) {
// Exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
final exponentialDelay = config.initialDelayMs *
pow(config.backoffMultiplier, attempt - 1).toInt();
// Cap at max delay
var delayMs = min(exponentialDelay, config.maxDelayMs);
// Add jitter to prevent thundering herd
if (config.useJitter) {
final jitter = _random.nextDouble() * 0.3; // ±30% jitter
delayMs = (delayMs * (1 + jitter - 0.15)).toInt();
}
return Duration(milliseconds: delayMs);
}
/// Determines if an error is retryable
bool _isRetryableError(dynamic error) {
// Network errors are retryable
if (error is TimeoutException) return true;
if (error is SocketException) return true;
// HTTP status codes that are retryable
if (error.toString().contains('500')) return true; // Internal Server Error
if (error.toString().contains('502')) return true; // Bad Gateway
if (error.toString().contains('503')) return true; // Service Unavailable
if (error.toString().contains('504')) return true; // Gateway Timeout
if (error.toString().contains('429')) return true; // Too Many Requests
// Client errors (4xx) are generally not retryable
if (error.toString().contains('400')) return false; // Bad Request
if (error.toString().contains('401')) return false; // Unauthorized
if (error.toString().contains('403')) return false; // Forbidden
if (error.toString().contains('404')) return false; // Not Found
// Default: don't retry unknown errors
return false;
}
}
/// Extension to add retry capability to Future operations
extension RetryExtension<T> on Future<T> Function() {
/// Executes this operation with retry logic
Future<T> withRetry({
RetryConfig? config,
bool Function(dynamic error)? shouldRetry,
void Function(int attempt, dynamic error, Duration delay)? onRetry,
}) {
final policy = RetryPolicy(config: config);
return policy.execute(
operation: this,
shouldRetry: shouldRetry,
onRetry: onRetry,
);
}
}

View File

@@ -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 linjection de dépendances
Future<void> setKey<T>(String key, T value) async => set<T>(key, value);
/// Délégation instance pour linjection 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,
});
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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;
};
}
}

View File

@@ -0,0 +1,4 @@
/// WebSocket core exports
library websocket;
export 'websocket_service.dart';

View File

@@ -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;
}
}