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

@@ -11,6 +11,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../shared/design_system/theme/app_theme_sophisticated.dart';
import '../features/authentication/presentation/bloc/auth_bloc.dart';
import '../core/l10n/locale_provider.dart';
import '../core/di/injection.dart';
import 'router/app_router.dart';
/// Application principale avec système d'authentification Keycloak
@@ -25,7 +26,7 @@ class UnionFlowApp extends StatelessWidget {
providers: [
ChangeNotifierProvider.value(value: localeProvider),
BlocProvider(
create: (context) => AuthBloc()..add(const AuthStatusChecked()),
create: (context) => getIt<AuthBloc>()..add(const AuthStatusChecked()),
),
],
child: Consumer<LocaleProvider>(
@@ -36,8 +37,8 @@ class UnionFlowApp extends StatelessWidget {
// Configuration du thème
theme: AppThemeSophisticated.lightTheme,
// darkTheme: AppThemeSophisticated.darkTheme,
// themeMode: ThemeMode.system,
darkTheme: AppThemeSophisticated.darkTheme,
themeMode: ThemeMode.system,
// Configuration de la localisation
locale: localeProvider.locale,

View File

@@ -7,6 +7,22 @@ import 'package:flutter/material.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 '../../features/about/presentation/pages/about_page.dart';
import '../../features/help/presentation/pages/help_support_page.dart';
import '../../features/profile/presentation/pages/profile_page_wrapper.dart';
import '../../features/organizations/presentation/pages/organizations_page.dart';
import '../../features/members/presentation/pages/members_page_wrapper.dart';
import '../../features/events/presentation/pages/events_page_wrapper.dart';
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
import '../../features/settings/presentation/pages/system_settings_page.dart';
import '../../features/dashboard/presentation/pages/advanced_dashboard_page.dart';
import '../../features/admin/presentation/pages/user_management_page.dart';
import '../../features/communication/presentation/pages/conversations_page.dart';
import '../../features/finance_workflow/presentation/pages/pending_approvals_page.dart';
import '../../features/finance_workflow/presentation/pages/budgets_list_page.dart';
import '../../core/navigation/main_navigation_layout.dart';
/// Configuration des routes de l'application
@@ -30,6 +46,28 @@ class AppRouter {
),
'/dashboard': (context) => const MainNavigationLayout(),
'/login': (context) => const LoginPage(),
'/about': (context) => const AboutPage(),
'/help': (context) => const HelpSupportPage(),
'/profile': (context) => const ProfilePageWrapper(),
'/organizations': (context) => const OrganizationsPage(),
'/members': (context) => const MembersPageWrapper(),
'/events': (context) => const EventsPageWrapper(),
'/solidarity': (context) => const DemandesAidePageWrapper(),
'/reports': (context) => const ReportsPageWrapper(),
'/finances': (context) => const ContributionsPageWrapper(),
'/my-finances': (context) => const ContributionsPageWrapper(),
'/moderation': (context) => const AdhesionsPageWrapper(),
'/communication': (context) => const ConversationsPage(),
'/org-settings': (context) => const SystemSettingsPage(),
'/analytics': (context) => const AdvancedDashboardPage(organizationId: '', userId: ''),
'/security': (context) => const SystemSettingsPage(),
'/system-admin': (context) => const MainNavigationLayout(),
'/global-users': (context) => const UserManagementPage(),
'/messages': (context) => const ConversationsPage(),
'/public-events': (context) => const EventsPageWrapper(),
'/contact': (context) => const HelpSupportPage(),
'/approvals': (context) => const PendingApprovalsPage(),
'/budgets': (context) => const BudgetsListPage(),
};
/// Route initiale de l'application

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

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform, kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
/// Page À propos - UnionFlow Mobile
@@ -35,9 +38,18 @@ class _AboutPageState extends State<AboutPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: UFAppBar(
title: 'À PROPOS',
actions: [
IconButton(
icon: const Icon(Icons.share_outlined, size: 20),
onPressed: _shareApp,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -70,62 +82,39 @@ class _AboutPageState extends State<AboutPage> {
);
}
/// Header harmonisé avec le design system
/// Header épuré
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(SpacingTokens.xl),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(SpacingTokens.xl),
boxShadow: [
BoxShadow(
color: ColorTokens.primary.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Row(
return Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
color: AppColors.primaryGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.info,
color: Colors.white,
size: 24,
Icons.account_balance,
color: AppColors.primaryGreen,
size: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'À propos de UnionFlow',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Version et informations de l\'application',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
const SizedBox(height: 16),
Text(
'UNIONFLOW MOBILE',
style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
Text(
'Gestion d\'associations et syndicats',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 8),
if (_packageInfo != null)
InfoBadge(
text: 'VERSION ${_packageInfo!.version}',
backgroundColor: AppColors.lightSurface,
textColor: AppColors.textSecondaryLight,
),
],
),
);
@@ -133,91 +122,18 @@ class _AboutPageState extends State<AboutPage> {
/// Section informations de l'application
Widget _buildAppInfoSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.mobile_friendly,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Informations de l\'application',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
Text(
'INFORMATIONS',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 16),
// Logo et nom de l'app
Center(
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(SpacingTokens.xxl),
),
child: const Icon(
Icons.account_balance,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 12),
const Text(
'UnionFlow Mobile',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 4),
Text(
'Gestion d\'associations et syndicats',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 20),
// Informations techniques
_buildInfoRow('Version', _packageInfo?.version ?? 'Chargement...'),
_buildInfoRow('Build', _packageInfo?.buildNumber ?? 'Chargement...'),
_buildInfoRow('Package', _packageInfo?.packageName ?? 'Chargement...'),
_buildInfoRow('Plateforme', 'Android/iOS'),
const SizedBox(height: 12),
_buildInfoRow('Construction', _packageInfo?.buildNumber ?? '...'),
_buildInfoRow('Package', _packageInfo?.packageName ?? '...'),
_buildInfoRow('Plateforme', 'Android / iOS'),
_buildInfoRow('Framework', 'Flutter 3.x'),
],
),
@@ -227,26 +143,18 @@ class _AboutPageState extends State<AboutPage> {
/// Ligne d'information
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
),
Flexible(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1F2937),
fontWeight: FontWeight.w600,
),
style: AppTypography.actionText.copyWith(fontSize: 12),
textAlign: TextAlign.end,
),
),
@@ -257,59 +165,26 @@ class _AboutPageState extends State<AboutPage> {
/// Section équipe de développement
Widget _buildTeamSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.group,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Équipe de développement',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
Text(
'ÉQUIPE',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
_buildTeamMember(
'UnionFlow Team',
'Développement & Architecture',
'Architecture & Dev',
Icons.code,
ColorTokens.primary,
AppColors.primaryGreen,
),
_buildTeamMember(
'Design System',
'Interface utilisateur & UX',
'UI / UX Focus',
Icons.design_services,
ColorTokens.info,
),
_buildTeamMember(
'Support Technique',
'Maintenance & Support',
Icons.support_agent,
ColorTokens.success,
AppColors.info,
),
],
),
@@ -319,41 +194,24 @@ class _AboutPageState extends State<AboutPage> {
/// Membre de l'équipe
Widget _buildTeamMember(String name, String role, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
role,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(name, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(role, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
@@ -364,72 +222,19 @@ class _AboutPageState extends State<AboutPage> {
/// Section fonctionnalités
Widget _buildFeaturesSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.featured_play_list,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Fonctionnalités principales',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildFeatureItem(
'Gestion des membres',
'Administration complète des adhérents',
Icons.people,
ColorTokens.primary,
),
_buildFeatureItem(
'Organisations',
'Gestion des syndicats et fédérations',
Icons.business,
ColorTokens.info,
),
_buildFeatureItem(
'Événements',
'Planification et suivi des événements',
Icons.event,
ColorTokens.success,
),
_buildFeatureItem(
'Tableau de bord',
'Statistiques et métriques en temps réel',
Icons.dashboard,
ColorTokens.warning,
),
_buildFeatureItem(
'Authentification sécurisée',
'Connexion via Keycloak OIDC',
Icons.security,
ColorTokens.tertiary,
Text(
'FONCTIONNALITÉS',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildFeatureItem('Membres', 'Administration complète', Icons.people, AppColors.primaryGreen),
_buildFeatureItem('Organisations', 'Syndicats & Fédérations', Icons.business, AppColors.info),
_buildFeatureItem('Événements', 'Planification & Suivi', Icons.event, AppColors.success),
_buildFeatureItem('Sécurité', 'Auth Keycloak OIDC', Icons.security, AppColors.warning),
],
),
);
@@ -438,41 +243,17 @@ class _AboutPageState extends State<AboutPage> {
/// Élément de fonctionnalité
Widget _buildFeatureItem(String title, String description, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
Icon(icon, color: color, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(description, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
@@ -483,66 +264,19 @@ class _AboutPageState extends State<AboutPage> {
/// Section liens utiles
Widget _buildLinksSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.link,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Liens utiles',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildLinkItem(
'Site web officiel',
'https://unionflow.com',
Icons.web,
() => _launchUrl('https://unionflow.com'),
),
_buildLinkItem(
'Documentation',
'Guide d\'utilisation complet',
Icons.book,
() => _launchUrl('https://docs.unionflow.com'),
),
_buildLinkItem(
'Code source',
'Projet open source sur GitHub',
Icons.code,
() => _launchUrl('https://github.com/unionflow/unionflow'),
),
_buildLinkItem(
'Politique de confidentialité',
'Protection de vos données',
Icons.privacy_tip,
() => _launchUrl('https://unionflow.com/privacy'),
Text(
'LIENS UTILES',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildLinkItem('Site Web', 'https://unionflow.com', Icons.web, () => _launchUrl('https://unionflow.com')),
_buildLinkItem('Documentation', 'Guide d\'utilisation', Icons.book, () => _launchUrl('https://docs.unionflow.com')),
_buildLinkItem('Confidentialité', 'Protection des données', Icons.privacy_tip, () => _launchUrl('https://unionflow.com/privacy')),
_buildLinkItem('Évaluer l\'app', 'Noter sur le store', Icons.star, _showRatingDialog),
],
),
);
@@ -552,143 +286,48 @@ class _AboutPageState extends State<AboutPage> {
Widget _buildLinkItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: ColorTokens.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.md),
),
child: Icon(
icon,
color: ColorTokens.primary,
size: 20,
),
),
const SizedBox(width: SpacingTokens.lg),
Icon(icon, color: AppColors.primaryGreen, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(subtitle, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey[400],
size: 16,
),
const Icon(Icons.chevron_right, color: AppColors.textSecondaryLight, size: 14),
],
),
),
);
}
/// Section support et contact
/// Section support
Widget _buildSupportSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.support_agent,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Support et contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
Text(
'SUPPORT',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 16),
_buildSupportItem(
'Support technique',
'support@unionflow.com',
Icons.email,
() => _launchUrl('mailto:support@unionflow.com'),
),
_buildSupportItem(
'Signaler un bug',
'Rapporter un problème technique',
Icons.bug_report,
() => _showBugReportDialog(),
),
_buildSupportItem(
'Suggérer une amélioration',
'Proposer de nouvelles fonctionnalités',
Icons.lightbulb,
() => _showFeatureRequestDialog(),
),
_buildSupportItem(
'Évaluer l\'application',
'Donner votre avis sur les stores',
Icons.star,
() => _showRatingDialog(),
),
const SizedBox(height: 20),
// Copyright et mentions légales
Center(
const SizedBox(height: 12),
_buildSupportItem('Email', 'support@unionflow.com', Icons.email, () => _launchUrl('mailto:support@unionflow.com')),
_buildSupportItem('Bug', 'Signaler un problème', Icons.bug_report, () => _showBugReportDialog()),
const SizedBox(height: 24),
const Center(
child: Column(
children: [
Text(
'© 2024 UnionFlow. Tous droits réservés.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Développé avec ❤️ pour les organisations syndicales',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
Text('© 2024 UNIONFLOW', style: AppTypography.badgeText),
Text('Fait avec ❤️ pour les syndicats', style: AppTypography.subtitleSmall),
],
),
),
@@ -701,51 +340,23 @@ class _AboutPageState extends State<AboutPage> {
Widget _buildSupportItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00B894).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFF00B894),
size: 20,
),
),
Icon(icon, color: AppColors.error, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(subtitle, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey[400],
size: 16,
),
const Icon(Icons.chevron_right, color: AppColors.textSecondaryLight, size: 14),
],
),
),
@@ -846,8 +457,7 @@ class _AboutPageState extends State<AboutPage> {
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Ici on pourrait utiliser un package comme in_app_review
_showErrorSnackBar('Fonctionnalité bientôt disponible');
_launchStoreForRating();
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
@@ -860,6 +470,45 @@ class _AboutPageState extends State<AboutPage> {
);
}
/// Partager les infos de l'app (titre, description, lien)
Future<void> _shareApp() async {
final version = _packageInfo != null
? '${_packageInfo!.version}+${_packageInfo!.buildNumber}'
: '';
await Share.share(
'Découvrez UnionFlow - Mouvement d\'entraide et de solidarité.\n'
'Version $version\n'
'https://unionflow.com',
subject: 'UnionFlow - Application mobile',
);
}
/// Ouvrir le store (Play Store / App Store) pour noter l'app
Future<void> _launchStoreForRating() async {
try {
final packageName = _packageInfo?.packageName ?? 'dev.lions.unionflow';
String storeUrl;
if (kIsWeb) {
storeUrl = 'https://unionflow.com';
} else if (defaultTargetPlatform == TargetPlatform.android) {
storeUrl = 'https://play.google.com/store/apps/details?id=$packageName';
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
// Remplacer par l'ID App Store réel une fois l'app publiée
storeUrl = 'https://apps.apple.com/app/id0000000000';
} else {
storeUrl = 'https://unionflow.com';
}
final uri = Uri.parse(storeUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
_showErrorSnackBar('Impossible d\'ouvrir le store');
}
} catch (e) {
_showErrorSnackBar('Erreur lors de l\'ouverture du store');
}
}
/// Afficher un message d'erreur
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -3,17 +3,21 @@ library adhesions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/adhesion_model.dart';
import '../data/repositories/adhesion_repository.dart';
part 'adhesions_event.dart';
part 'adhesions_state.dart';
@injectable
class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
final AdhesionRepository _repository;
AdhesionsBloc(this._repository) : super(const AdhesionsState()) {
on<LoadAdhesions>(_onLoadAdhesions);
on<LoadAdhesionsByMembre>(_onLoadAdhesionsByMembre);
on<LoadAdhesionsEnAttente>(_onLoadAdhesionsEnAttente);
on<LoadAdhesionsByStatut>(_onLoadAdhesionsByStatut);
on<LoadAdhesionById>(_onLoadAdhesionById);
@@ -34,6 +38,17 @@ class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
}
}
Future<void> _onLoadAdhesionsByMembre(LoadAdhesionsByMembre event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByMembre(event.membreId, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsEnAttente(LoadAdhesionsEnAttente event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
@@ -116,6 +131,13 @@ class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
try {
final stats = await _repository.getStats();
emit(state.copyWith(stats: stats));
} catch (_) {}
} catch (e, st) {
AppLogger.error('AdhesionsBloc: chargement stats échoué', error: e, stackTrace: st);
emit(state.copyWith(
status: AdhesionsStatus.error,
message: e.toString(),
error: e,
));
}
}
}

View File

@@ -14,6 +14,15 @@ class LoadAdhesions extends AdhesionsEvent {
List<Object?> get props => [page, size];
}
class LoadAdhesionsByMembre extends AdhesionsEvent {
final String membreId;
final int page;
final int size;
const LoadAdhesionsByMembre(this.membreId, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [membreId, page, size];
}
class LoadAdhesionsEnAttente extends AdhesionsEvent {
final int page;
final int size;

View File

@@ -3,6 +3,8 @@
library adhesion_repository;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/adhesion_model.dart';
abstract class AdhesionRepository {
@@ -24,30 +26,44 @@ abstract class AdhesionRepository {
Future<Map<String, dynamic>?> getStats();
}
@LazySingleton(as: AdhesionRepository)
class AdhesionRepositoryImpl implements AdhesionRepository {
final Dio _dio;
final ApiClient _apiClient;
static const String _base = '/api/adhesions';
AdhesionRepositoryImpl(this._dio);
AdhesionRepositoryImpl(this._apiClient);
/// Parse une réponse API : liste directe ou objet paginé avec clé "content".
List<AdhesionModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20}) async {
final response = await _dio.get(
final response = await _apiClient.get(
_base,
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel?> getById(String id) async {
final response = await _dio.get('$_base/$id');
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -59,7 +75,7 @@ class AdhesionRepositoryImpl implements AdhesionRepository {
Future<AdhesionModel> create(AdhesionModel adhesion) async {
final body = adhesion.toJson();
// Backend attend membreId, organisationId, fraisAdhesion, codeDevise (optionnel)
final response = await _dio.post(_base, data: body);
final response = await _apiClient.post(_base, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -68,7 +84,7 @@ class AdhesionRepositoryImpl implements AdhesionRepository {
@override
Future<AdhesionModel> approuver(String id, {String? approuvePar}) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_base/$id/approuver',
queryParameters: approuvePar != null ? {'approuvePar': approuvePar} : null,
);
@@ -80,7 +96,7 @@ class AdhesionRepositoryImpl implements AdhesionRepository {
@override
Future<AdhesionModel> rejeter(String id, String motifRejet) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_base/$id/rejeter',
queryParameters: {'motifRejet': motifRejet},
);
@@ -100,7 +116,7 @@ class AdhesionRepositoryImpl implements AdhesionRepository {
final q = <String, dynamic>{'montantPaye': montantPaye};
if (methodePaiement != null) q['methodePaiement'] = methodePaiement;
if (referencePaiement != null) q['referencePaiement'] = referencePaiement;
final response = await _dio.post('$_base/$id/paiement', queryParameters: q);
final response = await _apiClient.post('$_base/$id/paiement', queryParameters: q);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -109,67 +125,55 @@ class AdhesionRepositoryImpl implements AdhesionRepository {
@override
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20}) async {
final response = await _dio.get(
final response = await _apiClient.get(
'$_base/membre/$membreId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20}) async {
final response = await _dio.get(
final response = await _apiClient.get(
'$_base/organisation/$organisationId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20}) async {
final response = await _dio.get(
final response = await _apiClient.get(
'$_base/statut/$statut',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20}) async {
final response = await _dio.get(
final response = await _apiClient.get(
'$_base/en-attente',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<Map<String, dynamic>?> getStats() async {
final response = await _dio.get('$_base/stats');
final response = await _apiClient.get('$_base/stats');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}

View File

@@ -1,16 +0,0 @@
/// Configuration de l'injection de dépendances pour le module Adhésions
library adhesions_di;
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../bloc/adhesions_bloc.dart';
import '../data/repositories/adhesion_repository.dart';
void registerAdhesionsDependencies(GetIt getIt) {
getIt.registerLazySingleton<AdhesionRepository>(
() => AdhesionRepositoryImpl(getIt<Dio>()),
);
getIt.registerFactory<AdhesionsBloc>(
() => AdhesionsBloc(getIt<AdhesionRepository>()),
);
}

View File

@@ -1,13 +1,15 @@
/// Page détail d'une demande d'adhésion + actions (approuver, rejeter, paiement)
library adhesion_detail_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../widgets/paiement_adhesion_dialog.dart';
import '../widgets/rejet_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionDetailPage extends StatefulWidget {
final String adhesionId;
@@ -30,8 +32,11 @@ class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détail adhésion'),
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'DÉTAIL ADHÉSION',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
),
body: BlocConsumer<AdhesionsBloc, AdhesionsState>(
listenWhen: (prev, curr) => prev.status != curr.status,
@@ -73,9 +78,11 @@ class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
title: 'Référence',
value: a.numeroReference ?? a.id ?? '',
),
const SizedBox(height: 12),
_InfoCard(title: 'Statut', value: a.statutLibelle),
const SizedBox(height: 12),
_InfoCard(
title: 'Statut',
value: a.statutLibelle,
trail: _buildStatutBadge(a.statut),
),
_InfoCard(
title: 'Organisation',
value: a.nomOrganisation ?? a.organisationId ?? '',
@@ -109,8 +116,7 @@ class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
),
if (a.motifRejet != null && a.motifRejet!.isNotEmpty)
_InfoCard(title: 'Motif rejet', value: a.motifRejet!),
const SizedBox(height: 24),
_ActionsSection(adhesion: a, currencyFormat: _currencyFormat),
_ActionsSection(adhesion: a, currencyFormat: _currencyFormat, isGestionnaire: _isGestionnaire()),
],
),
);
@@ -118,93 +124,156 @@ class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
),
);
}
bool _isGestionnaire() {
final state = context.read<AuthBloc>().state;
if (state is AuthAuthenticated) {
return state.effectiveRole.level >= 50;
}
return false;
}
}
class _InfoCard extends StatelessWidget {
final String title;
final String value;
final Widget? trail;
const _InfoCard({required this.title, required this.value});
const _InfoCard({required this.title, required this.value, this.trail});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey[700],
return CoreCard(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
fontSize: 9,
color: AppColors.textSecondaryLight,
),
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.bodyTextSmall.copyWith(fontSize: 12),
),
],
),
Expanded(child: Text(value)),
],
),
),
if (trail != null) trail!,
],
),
);
}
}
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'APPROUVEE':
case 'PAYEE':
color = AppColors.success;
break;
case 'REJETEE':
case 'ANNULEE':
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = AppColors.textSecondaryLight;
}
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
class _ActionsSection extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final bool isGestionnaire;
const _ActionsSection({
required this.adhesion,
required this.currencyFormat,
required this.isGestionnaire,
});
@override
Widget build(BuildContext context) {
if (!isGestionnaire) return const SizedBox.shrink(); // Normal members cannot approve/pay an adhesion on someone else's behalf (or their own) currently in the UI design.
final bloc = context.read<AdhesionsBloc>();
if (adhesion.statut == 'EN_ATTENTE') {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Actions (admin)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () {
if (adhesion.id == null) return;
bloc.add(ApprouverAdhesion(adhesion.id!));
},
icon: const Icon(Icons.check_circle),
label: const Text('Approuver'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'ACTIONS ADMINISTRATIVES',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
if (adhesion.id == null) return;
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: RejetAdhesionDialog(
adhesionId: adhesion.id!,
onRejected: () => Navigator.of(ctx).pop(),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (adhesion.id == null) return;
bloc.add(ApprouverAdhesion(adhesion.id!));
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('APPROUVER', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
);
},
icon: const Icon(Icons.cancel),
label: const Text('Rejeter'),
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: () {
if (adhesion.id == null) return;
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: RejetAdhesionDialog(
adhesionId: adhesion.id!,
onRejected: () => Navigator.of(ctx).pop(),
),
),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('REJETER', style: AppTypography.actionText.copyWith(fontSize: 11)),
),
),
],
),
],
);
@@ -213,14 +282,18 @@ class _ActionsSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Paiement',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'PAIEMENT',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
ElevatedButton(
onPressed: () {
showDialog<void>(
context: context,
@@ -234,8 +307,14 @@ class _ActionsSection extends StatelessWidget {
),
);
},
icon: const Icon(Icons.payment),
label: const Text('Enregistrer un paiement'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('ENREGISTRER UN PAIEMENT', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
],
);

View File

@@ -1,13 +1,15 @@
/// Page liste des demandes d'adhésion
library adhesions_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import 'adhesion_detail_page.dart';
import '../widgets/create_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionsPage extends StatefulWidget {
const AdhesionsPage({super.key});
@@ -25,7 +27,7 @@ class _AdhesionsPageState extends State<AdhesionsPage>
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
context.read<AdhesionsBloc>().add(const LoadAdhesions());
_loadTab(0);
}
@override
@@ -35,19 +37,34 @@ class _AdhesionsPageState extends State<AdhesionsPage>
}
void _loadTab(int index) {
switch (index) {
case 0:
context.read<AdhesionsBloc>().add(const LoadAdhesions());
break;
case 1:
context.read<AdhesionsBloc>().add(const LoadAdhesionsEnAttente());
break;
case 2:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('APPROUVEE'));
break;
case 3:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('PAYEE'));
break;
bool isGestionnaire = false;
String? membreId;
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
isGestionnaire = authState.effectiveRole.level >= 50;
membreId = authState.user.id;
}
if (isGestionnaire) {
switch (index) {
case 0:
context.read<AdhesionsBloc>().add(const LoadAdhesions());
break;
case 1:
context.read<AdhesionsBloc>().add(const LoadAdhesionsEnAttente());
break;
case 2:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('APPROUVEE'));
break;
case 3:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('PAYEE'));
break;
}
} else {
// Normal member: always fetch their own records to ensure security
if (membreId != null) {
context.read<AdhesionsBloc>().add(LoadAdhesionsByMembre(membreId));
}
}
}
@@ -70,25 +87,34 @@ class _AdhesionsPageState extends State<AdhesionsPage>
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Demandes d\'adhésion'),
bottom: TabBar(
controller: _tabController,
onTap: _loadTab,
tabs: const [
Tab(text: 'Toutes', icon: Icon(Icons.list)),
Tab(text: 'En attente', icon: Icon(Icons.schedule)),
Tab(text: 'Approuvées', icon: Icon(Icons.check_circle_outline)),
Tab(text: 'Payées', icon: Icon(Icons.payment)),
],
),
backgroundColor: AppColors.background,
appBar: UFAppBar(
title: 'ADHÉSIONS',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
actions: [
IconButton(
icon: const Icon(Icons.add),
icon: const Icon(Icons.add, size: 20),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle demande',
),
],
bottom: TabBar(
controller: _tabController,
onTap: _loadTab,
isScrollable: true,
labelColor: AppColors.primaryGreen,
unselectedLabelColor: AppColors.textSecondaryLight,
indicatorColor: AppColors.primaryGreen,
indicatorSize: TabBarIndicatorSize.label,
labelStyle: AppTypography.actionText.copyWith(fontSize: 10, fontWeight: FontWeight.bold),
tabs: const [
Tab(child: Text('TOUTES')),
Tab(child: Text('ATTENTE')),
Tab(child: Text('APPROUVÉES')),
Tab(child: Text('PAYÉES')),
],
),
),
body: TabBarView(
controller: _tabController,
@@ -193,106 +219,96 @@ class _AdhesionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return CoreCard(
margin: const EdgeInsets.only(bottom: 10),
onTap: onTap,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
Expanded(
child: Text(
adhesion.numeroReference ?? adhesion.id ?? '',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_StatutChip(statut: adhesion.statut),
],
),
const SizedBox(height: 4),
Text(
adhesion.nomOrganisation ?? adhesion.organisationId ?? 'Organisation',
style: theme.textTheme.bodyMedium,
),
if (adhesion.nomMembreComplet.isNotEmpty)
Text(
adhesion.nomMembreComplet,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 4),
Row(
children: [
Text(
adhesion.fraisAdhesion != null
? currencyFormat.format(adhesion.fraisAdhesion)
: '',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
if (adhesion.dateDemande != null) ...[
const Spacer(),
const MiniAvatar(size: 24, fallbackText: '🏢'),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('dd/MM/yyyy').format(adhesion.dateDemande!),
style: theme.textTheme.bodySmall,
adhesion.nomOrganisation ?? adhesion.organisationId ?? 'Organisation',
style: AppTypography.actionText.copyWith(fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
adhesion.numeroReference ?? adhesion.id?.substring(0, 8) ?? '',
style: AppTypography.subtitleSmall.copyWith(fontSize: 9),
),
],
],
),
),
_buildStatutBadge(adhesion.statut),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('FRAIS D\'ADHÉSION', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
adhesion.fraisAdhesion != null ? currencyFormat.format(adhesion.fraisAdhesion) : '',
style: AppTypography.headerSmall.copyWith(fontSize: 13, color: AppColors.primaryGreen),
),
],
),
if (adhesion.dateDemande != null)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('DATE', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
DateFormat('dd/MM/yyyy').format(adhesion.dateDemande!),
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10),
),
],
),
],
),
if (adhesion.nomMembreComplet.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'MEMBRE : ${adhesion.nomMembreComplet.toUpperCase()}',
style: AppTypography.subtitleSmall.copyWith(fontSize: 8, color: AppColors.textSecondaryLight),
),
],
],
),
);
}
}
class _StatutChip extends StatelessWidget {
final String? statut;
const _StatutChip({this.statut});
@override
Widget build(BuildContext context) {
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'EN_ATTENTE':
color = Colors.orange;
break;
case 'APPROUVEE':
case 'PAYEE':
color = Colors.green;
color = AppColors.success;
break;
case 'REJETEE':
color = Colors.red;
break;
case 'ANNULEE':
color = Colors.grey;
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = Colors.grey;
color = AppColors.textSecondaryLight;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
statut ?? '',
style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500),
),
);
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
}

View File

@@ -4,12 +4,13 @@ library create_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/data/repositories/organization_repository.dart';
import '../../../members/data/services/membre_search_service.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateAdhesionDialog extends StatefulWidget {
final VoidCallback onCreated;
@@ -22,16 +23,42 @@ class CreateAdhesionDialog extends StatefulWidget {
class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
final _fraisController = TextEditingController();
String? _membreId;
String? _organisationId;
bool _loading = false;
bool _isInitLoading = true;
List<OrganizationModel> _organisations = [];
List<MembreCompletModel> _membres = [];
MembreCompletModel? _me;
@override
void initState() {
super.initState();
_loadOrgs();
_loadInitialData();
}
Future<void> _loadInitialData() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
final orgRepo = GetIt.instance<IOrganizationRepository>();
final list = await orgRepo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_me = user;
_organisations = list;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateAdhesionDialog: chargement profil/organisations échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil ou les organisations. Réessayez.')),
);
}
}
}
@override
@@ -40,32 +67,14 @@ class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
super.dispose();
}
Future<void> _loadOrgs() async {
try {
final repo = GetIt.instance<OrganizationRepository>();
final list = await repo.getOrganizations(page: 0, size: 100);
if (mounted) setState(() => _organisations = list);
} catch (_) {
if (mounted) setState(() {});
}
}
Future<void> _searchMembres(String query) async {
if (query.length < 2) {
setState(() => _membres = []);
void _submit() {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil non chargé, veuillez réessayer')),
);
return;
}
try {
final service = GetIt.instance<MembreSearchService>();
final result = await service.quickSearch(query: query, size: 20);
if (mounted) setState(() => _membres = result.membres);
} catch (_) {
if (mounted) setState(() => _membres = []);
}
}
void _submit() {
if (_membreId == null || _organisationId == null) {
if (_organisationId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez sélectionner un membre et une organisation')),
);
@@ -80,7 +89,7 @@ class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
}
setState(() => _loading = true);
final adhesion = AdhesionModel(
membreId: _membreId,
membreId: _me!.id,
organisationId: _organisationId,
fraisAdhesion: frais,
codeDevise: 'XOF',
@@ -102,32 +111,24 @@ class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Rechercher un membre (nom, prénom)',
border: OutlineInputBorder(),
),
onChanged: _searchMembres,
enabled: !_loading,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _membreId,
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
),
items: _membres
.map((m) => DropdownMenuItem<String>(
value: m.id,
child: Text('${m.prenom} ${m.nom}'),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _membreId = v),
),
if (_isInitLoading)
const CircularProgressIndicator()
else if (_me != null)
TextFormField(
initialValue: '${_me!.prenom} ${_me!.nom}',
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: false,
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _organisationId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
@@ -135,7 +136,7 @@ class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
items: _organisations
.map((o) => DropdownMenuItem<String>(
value: o.id,
child: Text(o.nom),
child: Text(o.nom, overflow: TextOverflow.ellipsis, maxLines: 1),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _organisationId = v),

View File

@@ -3,6 +3,7 @@ library paiement_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../shared/constants/payment_method_assets.dart';
import '../../bloc/adhesions_bloc.dart';
class PaiementAdhesionDialog extends StatefulWidget {
@@ -40,6 +41,25 @@ class _PaiementAdhesionDialogState extends State<PaiementAdhesionDialog> {
super.dispose();
}
List<DropdownMenuItem<String>> _buildPaymentMethodItems() {
const codes = ['ESPECES', 'VIREMENT', 'WAVE_MONEY', 'ORANGE_MONEY', 'CHEQUE'];
const labels = {'ESPECES': 'Espèces', 'VIREMENT': 'Virement', 'WAVE_MONEY': 'Wave Money', 'ORANGE_MONEY': 'Orange Money', 'CHEQUE': 'Chèque'};
return codes.map((code) {
final label = labels[code] ?? code;
return DropdownMenuItem<String>(
value: code,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PaymentMethodIcon(paymentMethodCode: code, width: 24, height: 24),
const SizedBox(width: 12),
Text(label),
],
),
);
}).toList();
}
void _submit() {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
@@ -98,13 +118,7 @@ class _PaiementAdhesionDialogState extends State<PaiementAdhesionDialog> {
labelText: 'Méthode de paiement',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'ESPECES', child: Text('Espèces')),
DropdownMenuItem(value: 'VIREMENT', child: Text('Virement')),
DropdownMenuItem(value: 'WAVE_MONEY', child: Text('Wave Money')),
DropdownMenuItem(value: 'ORANGE_MONEY', child: Text('Orange Money')),
DropdownMenuItem(value: 'CHEQUE', child: Text('Chèque')),
],
items: _buildPaymentMethodItems(),
onChanged: _loading ? null : (v) => setState(() => _methode = v),
),
const SizedBox(height: 12),

View File

@@ -22,6 +22,7 @@ class RejetAdhesionDialog extends StatefulWidget {
class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
final _controller = TextEditingController();
bool _loading = false;
bool _rejectSent = false;
@override
void dispose() {
@@ -37,18 +38,36 @@ class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
);
return;
}
setState(() => _loading = true);
setState(() {
_loading = true;
_rejectSent = true;
});
context.read<AdhesionsBloc>().add(RejeterAdhesion(widget.adhesionId, motif));
widget.onRejected();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
return BlocListener<AdhesionsBloc, AdhesionsState>(
listenWhen: (_, state) => _rejectSent && (state.status == AdhesionsStatus.loaded || state.status == AdhesionsStatus.error),
listener: (context, state) {
if (!_rejectSent || !mounted) return;
if (state.status == AdhesionsStatus.error) {
setState(() {
_loading = false;
_rejectSent = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message ?? 'Erreur lors du rejet')),
);
return;
}
if (state.status == AdhesionsStatus.loaded) {
setState(() => _rejectSent = false);
widget.onRejected();
Navigator.of(context).pop();
}
},
child: AlertDialog(
title: const Text('Rejeter la demande'),
content: TextField(
controller: _controller,
@@ -77,6 +96,7 @@ class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
: const Text('Rejeter'),
),
],
),
);
}
}

View File

@@ -1,11 +1,13 @@
library admin_users_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../data/models/admin_user_model.dart';
import '../data/repositories/admin_user_repository.dart';
import 'admin_users_event.dart';
import 'admin_users_state.dart';
part 'admin_users_event.dart';
part 'admin_users_state.dart';
@injectable
class AdminUsersBloc extends Bloc<AdminUsersEvent, AdminUsersState> {
final AdminUserRepository _repository;

View File

@@ -1,4 +1,4 @@
library admin_users_event;
part of 'admin_users_bloc.dart';
abstract class AdminUsersEvent {}

View File

@@ -1,6 +1,4 @@
library admin_users_state;
import '../data/models/admin_user_model.dart';
part of 'admin_users_bloc.dart';
abstract class AdminUsersState {}

View File

@@ -2,6 +2,8 @@
library admin_user_repository;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/admin_user_model.dart';
abstract class AdminUserRepository {
@@ -10,6 +12,8 @@ abstract class AdminUserRepository {
Future<List<AdminRoleModel>> getRealmRoles();
Future<List<AdminRoleModel>> getUserRoles(String userId);
Future<void> setUserRoles(String userId, List<String> roleNames);
/// Associe un utilisateur (email) à une organisation (réservé SUPER_ADMIN).
Future<void> associerOrganisation({required String email, required String organisationId});
}
class AdminUserSearchResult {
@@ -28,17 +32,18 @@ class AdminUserSearchResult {
});
}
@LazySingleton(as: AdminUserRepository)
class AdminUserRepositoryImpl implements AdminUserRepository {
final Dio _dio;
final ApiClient _apiClient;
static const String _base = '/api/admin/users';
AdminUserRepositoryImpl(this._dio);
AdminUserRepositoryImpl(this._apiClient);
@override
Future<AdminUserSearchResult> search({int page = 0, int size = 20, String? search}) async {
final query = <String, dynamic>{'page': page, 'size': size};
if (search != null && search.isNotEmpty) query['search'] = search;
final response = await _dio.get(_base, queryParameters: query);
final response = await _apiClient.get(_base, queryParameters: query);
if (response.statusCode != 200) throw Exception('Erreur ${response.statusCode}');
final data = response.data as Map<String, dynamic>;
final list = data['users'] as List<dynamic>? ?? [];
@@ -53,7 +58,7 @@ class AdminUserRepositoryImpl implements AdminUserRepository {
@override
Future<AdminUserModel?> getById(String id) async {
final response = await _dio.get('$_base/$id');
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return AdminUserModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -63,7 +68,7 @@ class AdminUserRepositoryImpl implements AdminUserRepository {
@override
Future<List<AdminRoleModel>> getRealmRoles() async {
final response = await _dio.get('$_base/roles');
final response = await _apiClient.get('$_base/roles');
if (response.statusCode != 200) return [];
final list = response.data as List<dynamic>? ?? [];
return list.map((e) => AdminRoleModel.fromJson(e as Map<String, dynamic>)).toList();
@@ -71,7 +76,7 @@ class AdminUserRepositoryImpl implements AdminUserRepository {
@override
Future<List<AdminRoleModel>> getUserRoles(String userId) async {
final response = await _dio.get('$_base/$userId/roles');
final response = await _apiClient.get('$_base/$userId/roles');
if (response.statusCode != 200) return [];
final list = response.data as List<dynamic>? ?? [];
return list.map((e) => AdminRoleModel.fromJson(e as Map<String, dynamic>)).toList();
@@ -79,7 +84,22 @@ class AdminUserRepositoryImpl implements AdminUserRepository {
@override
Future<void> setUserRoles(String userId, List<String> roleNames) async {
final response = await _dio.put('$_base/$userId/roles', data: roleNames);
final response = await _apiClient.put('$_base/$userId/roles', data: roleNames);
if (response.statusCode != 200) throw Exception('Erreur ${response.statusCode}');
}
@override
Future<void> associerOrganisation({required String email, required String organisationId}) async {
const path = '/api/admin/associer-organisation';
final response = await _apiClient.post(
path,
data: {'email': email, 'organisationId': organisationId},
);
if (response.statusCode != 200) {
final msg = response.data is Map && response.data['message'] != null
? response.data['message'] as String
: 'Erreur ${response.statusCode}';
throw Exception(msg);
}
}
}

View File

@@ -1,15 +0,0 @@
library admin_di;
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../bloc/admin_users_bloc.dart';
import '../data/repositories/admin_user_repository.dart';
void registerAdminDependencies(GetIt getIt) {
getIt.registerLazySingleton<AdminUserRepository>(
() => AdminUserRepositoryImpl(getIt<Dio>()),
);
getIt.registerFactory<AdminUsersBloc>(
() => AdminUsersBloc(getIt<AdminUserRepository>()),
);
}

View File

@@ -1,9 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import '../../bloc/admin_users_bloc.dart';
import '../../bloc/admin_users_event.dart';
import '../../bloc/admin_users_state.dart';
import '../../data/models/admin_user_model.dart';
import '../../data/repositories/admin_user_repository.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/data/services/organization_service.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/design_system/components/uf_app_bar.dart';
import '../../../../shared/design_system/components/uf_buttons.dart';
/// Page détail d'un utilisateur + édition des rôles
class UserManagementDetailPage extends StatelessWidget {
@@ -14,10 +21,9 @@ class UserManagementDetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détail utilisateur'),
backgroundColor: const Color(0xFF0984E3),
foregroundColor: Colors.white,
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'Détail utilisateur',
),
body: BlocBuilder<AdminUsersBloc, AdminUsersState>(
builder: (context, state) {
@@ -95,28 +101,42 @@ class _UserDetailContentState extends State<_UserDetailContent> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.user.displayName, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
if (widget.user.email != null) Text('Email: ${widget.user.email}'),
if (widget.user.username != null) Text('Username: ${widget.user.username}'),
Text('Actif: ${widget.user.enabled == true ? "Oui" : "Non"}'),
],
),
CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.user.displayName, style: AppTypography.headerSmall),
const SizedBox(height: 8),
if (widget.user.email != null)
Text('Email: ${widget.user.email}', style: AppTypography.bodyTextSmall),
if (widget.user.username != null)
Text('Username: ${widget.user.username}', style: AppTypography.bodyTextSmall),
Text(
'Statut: ${widget.user.enabled == true ? "Actif" : "Inactif"}',
style: AppTypography.bodyTextSmall.copyWith(
color: widget.user.enabled == true ? AppColors.success : AppColors.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
const Text('Rôles (cochez pour attribuer)', style: TextStyle(fontWeight: FontWeight.bold)),
Text(
'RÔLES (SÉLECTION)',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
const SizedBox(height: 8),
...widget.allRoles.map((role) {
final selected = _selectedRoleNames.contains(role.name);
return CheckboxListTile(
title: Text(role.name),
title: Text(role.name, style: AppTypography.bodyTextSmall),
activeColor: AppColors.primaryGreen,
contentPadding: EdgeInsets.zero,
dense: true,
value: selected,
onChanged: (v) {
setState(() {
@@ -130,19 +150,39 @@ class _UserDetailContentState extends State<_UserDetailContent> {
);
}),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0984E3),
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () {
context.read<AdminUsersBloc>().add(
AdminUserRolesUpdateRequested(widget.userId, _selectedRoleNames.toList()),
);
},
child: const Text('Enregistrer les rôles'),
UFPrimaryButton(
label: 'Enregistrer les rôles',
onPressed: () {
context.read<AdminUsersBloc>().add(
AdminUserRolesUpdateRequested(widget.userId, _selectedRoleNames.toList()),
);
},
),
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 16),
Text(
'ASSOCIER À UNE ORGANISATION',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
const SizedBox(height: 8),
Text(
'Permet à cet utilisateur (ex. admin d\'organisation) de voir « Mes organisations » et d\'accéder au dashboard de l\'organisation.',
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: widget.user.email == null || widget.user.email!.isEmpty
? null
: () => _openAssocierOrganisationDialog(context, widget.user.email!),
icon: const Icon(Icons.business, size: 18),
label: const Text('Associer à une organisation'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
side: const BorderSide(color: AppColors.primaryGreen),
),
),
],
@@ -150,4 +190,88 @@ class _UserDetailContentState extends State<_UserDetailContent> {
),
);
}
Future<void> _openAssocierOrganisationDialog(BuildContext context, String userEmail) async {
final orgService = GetIt.I<OrganizationService>();
final adminRepo = GetIt.I<AdminUserRepository>();
List<OrganizationModel> organisations = [];
try {
organisations = await orgService.getOrganizations(page: 0, size: 200);
} catch (e, st) {
AppLogger.error('UserManagementDetail: chargement organisations échoué', error: e, stackTrace: st);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger les organisations')),
);
return;
}
if (!context.mounted) return;
final orgsWithId = organisations.where((o) => o.id != null && o.id!.isNotEmpty).toList();
if (orgsWithId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucune organisation disponible. Créez-en une d\'abord.')),
);
return;
}
String? selectedOrgId = orgsWithId.first.id;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx2, setDialogState) {
return AlertDialog(
title: const Text('Associer à une organisation'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Utilisateur: $userEmail', style: AppTypography.bodyTextSmall),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedOrgId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
),
items: orgsWithId
.map((o) => DropdownMenuItem(value: o.id, child: Text(o.nom, overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setDialogState(() => selectedOrgId = v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
if (selectedOrgId == null) return;
try {
await adminRepo.associerOrganisation(email: userEmail, organisationId: selectedOrgId!);
if (ctx.mounted) Navigator.of(ctx).pop();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Utilisateur associé à l\'organisation avec succès.')),
);
}
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text('Erreur: ${e.toString().replaceFirst('Exception: ', '')}')),
);
}
}
},
child: const Text('Associer'),
),
],
);
},
),
);
}
}

View File

@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/admin_users_bloc.dart';
import '../../bloc/admin_users_event.dart';
import '../../bloc/admin_users_state.dart';
import '../../data/models/admin_user_model.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../../../shared/design_system/components/uf_app_bar.dart';
import 'user_management_detail_page.dart';
/// Page de gestion des utilisateurs (SUPER_ADMIN) - liste paginée
@@ -39,13 +41,12 @@ class _UserManagementViewState extends State<_UserManagementView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des utilisateurs'),
backgroundColor: const Color(0xFF0984E3),
foregroundColor: Colors.white,
backgroundColor: AppColors.background,
appBar: UFAppBar(
title: 'Gestion des utilisateurs',
actions: [
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh, size: 20),
onPressed: () => context.read<AdminUsersBloc>().add(AdminUsersLoadRequested()),
),
],
@@ -58,9 +59,23 @@ class _UserManagementViewState extends State<_UserManagementView> {
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher (email, nom...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
hintStyle: AppTypography.subtitleSmall,
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.primaryGreen),
),
filled: true,
fillColor: AppColors.lightSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onSubmitted: (v) => context.read<AdminUsersBloc>().add(
AdminUsersLoadRequested(search: v.isEmpty ? null : v),
@@ -120,27 +135,45 @@ class _UserManagementViewState extends State<_UserManagementView> {
}
Widget _buildUserTile(BuildContext context, AdminUserModel user) {
return Card(
return CoreCard(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF0984E3),
child: Text(
(user.prenom?.substring(0, 1) ?? user.username?.substring(0, 1) ?? '?').toUpperCase(),
style: const TextStyle(color: Colors.white),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (_) => GetIt.I<AdminUsersBloc>()..add(AdminUserDetailWithRolesRequested(user.id)),
child: UserManagementDetailPage(userId: user.id),
),
),
title: Text(user.displayName),
subtitle: Text(user.email ?? user.username ?? user.id),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (_) => GetIt.I<AdminUsersBloc>()..add(AdminUserDetailWithRolesRequested(user.id)),
child: UserManagementDetailPage(userId: user.id),
),
child: Row(
children: [
MiniAvatar(
imageUrl: null, // AdminUserModel n'a pas de champ avatar
fallbackText: (user.prenom?.substring(0, 1) ?? user.username?.substring(0, 1) ?? '?').toUpperCase(),
size: 36,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: AppTypography.actionText,
),
Text(
user.email ?? user.username ?? user.id,
style: AppTypography.subtitleSmall,
),
],
),
),
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.textSecondaryLight,
),
],
),
);
}

View File

@@ -1,71 +0,0 @@
/// Gestionnaire de cache pour le dashboard
/// Cache intelligent basé sur les rôles utilisateurs
library dashboard_cache_manager;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/user_role.dart';
/// Gestionnaire de cache pour optimiser les performances du dashboard
class DashboardCacheManager {
static final Map<String, dynamic> _cache = {};
static final Map<String, DateTime> _cacheTimestamps = {};
static const Duration _cacheExpiry = Duration(minutes: 15);
/// Invalide le cache pour un rôle spécifique
static Future<void> invalidateForRole(UserRole role) async {
final keysToRemove = _cache.keys
.where((key) => key.startsWith('dashboard_${role.name}'))
.toList();
for (final key in keysToRemove) {
_cache.remove(key);
_cacheTimestamps.remove(key);
}
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
}
/// Vide complètement le cache
static Future<void> clear() async {
_cache.clear();
_cacheTimestamps.clear();
debugPrint('🧹 Cache dashboard complètement vidé');
}
/// Obtient une valeur du cache
static T? get<T>(String key) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) return null;
// Vérifier l'expiration
if (DateTime.now().difference(timestamp) > _cacheExpiry) {
_cache.remove(key);
_cacheTimestamps.remove(key);
return null;
}
return _cache[key] as T?;
}
/// Met une valeur en cache
static void set<T>(String key, T value) {
_cache[key] = value;
_cacheTimestamps[key] = DateTime.now();
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getStats() {
final now = DateTime.now();
final validEntries = _cacheTimestamps.entries
.where((entry) => now.difference(entry.value) <= _cacheExpiry)
.length;
return {
'totalEntries': _cache.length,
'validEntries': validEntries,
'expiredEntries': _cache.length - validEntries,
'cacheHitRate': '${(validEntries / _cache.length * 100).toStringAsFixed(1)}%',
};
}
}

View File

@@ -1,419 +1,183 @@
/// Service d'Authentification Keycloak
/// Gère l'authentification avec votre instance Keycloak sur port 8180
library keycloak_auth_service;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:injectable/injectable.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import 'keycloak_webview_auth_service.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/utils/logger.dart';
/// Configuration Keycloak pour votre instance
/// Configuration Keycloak centralisée
class KeycloakConfig {
/// URL de base de votre Keycloak (depuis AppConfig)
static String get baseUrl => AppConfig.keycloakBaseUrl;
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
static const String scopes = 'openid profile email roles';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout';
}
/// Service d'authentification Keycloak ultra-sophistiqué
/// Service d'Authentification Keycloak Épuré & DRY
@lazySingleton
class KeycloakAuthService {
static const FlutterAppAuth _appAuth = FlutterAppAuth();
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
final Dio _dio = Dio();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_access_token';
static const String _refreshTokenKey = 'keycloak_refresh_token';
static const String _idTokenKey = 'keycloak_id_token';
static const String _userInfoKey = 'keycloak_user_info';
/// Authentification avec Keycloak via WebView (solution HTTP compatible)
///
/// Cette méthode utilise maintenant KeycloakWebViewAuthService pour contourner
/// les limitations HTTPS de flutter_appauth
static Future<AuthorizationTokenResponse?> authenticate() async {
static const String _accessK = 'kc_access';
static const String _refreshK = 'kc_refresh';
static const String _idK = 'kc_id';
/// Login via Direct Access Grant (Username/Password)
Future<User?> login(String username, String password) async {
try {
debugPrint('🔐 Démarrage authentification Keycloak via WebView...');
final response = await _dio.post(
KeycloakConfig.tokenEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'grant_type': 'password',
'username': username,
'password': password,
'scope': KeycloakConfig.scopes,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
// Utiliser le service WebView pour l'authentification
// Cette méthode retourne null car l'authentification WebView
// est gérée différemment (via callback)
debugPrint('💡 Authentification WebView - utilisez authenticateWithWebView() à la place');
if (response.statusCode == 200) {
await _saveTokens(response.data);
return await getCurrentUser();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: auth error', error: e, stackTrace: st);
}
return null;
}
return null;
static Future<String?>? _refreshFuture;
} catch (e, stackTrace) {
debugPrint('💥 Erreur authentification Keycloak: $e');
debugPrint('Stack trace: $stackTrace');
return null;
/// Rafraîchissement automatique du token avec verrouillage global
Future<String?> refreshToken() async {
if (_refreshFuture != null) {
AppLogger.info('KeycloakAuthService: waiting for ongoing refresh');
return await _refreshFuture;
}
_refreshFuture = _performRefresh();
try {
return await _refreshFuture;
} finally {
_refreshFuture = null;
}
}
/// Rafraîchit le token d'accès
static Future<TokenResponse?> refreshToken() async {
Future<String?> _performRefresh() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null) {
AppLogger.info('KeycloakAuthService: no refresh token available');
return null;
}
try {
final String? refreshToken = await _secureStorage.read(
key: _refreshTokenKey,
);
if (refreshToken == null) {
debugPrint('❌ Aucun refresh token disponible');
return null;
}
debugPrint('🔄 Rafraîchissement du token...');
final TokenRequest request = TokenRequest(
KeycloakConfig.clientId,
KeycloakConfig.redirectUrl,
refreshToken: refreshToken,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
AppLogger.info('KeycloakAuthService: attempting token refresh');
final response = await _dio.post(
KeycloakConfig.tokenEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'grant_type': 'refresh_token',
'refresh_token': refresh,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
validateStatus: (status) => status == 200,
),
);
final TokenResponse? result = await _appAuth.token(request);
if (result != null) {
await _storeTokens(result);
debugPrint('✅ Token rafraîchi avec succès');
return result;
if (response.statusCode == 200) {
await _saveTokens(response.data);
AppLogger.info('KeycloakAuthService: token refreshed successfully');
return response.data['access_token'];
}
debugPrint('❌ Échec du rafraîchissement du token');
return null;
} catch (e, stackTrace) {
debugPrint('💥 Erreur rafraîchissement token: $e');
debugPrint('Stack trace: $stackTrace');
return null;
} on DioException catch (e, st) {
AppLogger.error('KeycloakAuthService: refresh error ${e.response?.statusCode}', error: e, stackTrace: st);
if (e.response?.statusCode == 400) {
AppLogger.info('KeycloakAuthService: refresh token invalid or expired, logging out');
await logout();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: critical refresh error', error: e, stackTrace: st);
}
return null;
}
/// Récupère l'utilisateur authentifié depuis les tokens
static Future<User?> getCurrentUser() async {
/// Récupération de l'utilisateur courant + Mapage Rôles
Future<User?> getCurrentUser() async {
String? token = await _storage.read(key: _accessK);
final idToken = await _storage.read(key: _idK);
if (token == null || idToken == null) return null;
if (JwtDecoder.isExpired(token)) {
token = await refreshToken();
if (token == null) return null;
}
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
final String? idToken = await _secureStorage.read(
key: _idTokenKey,
);
if (accessToken == null || idToken == null) {
debugPrint('❌ Tokens manquants');
return null;
}
// Vérifier si les tokens sont expirés
if (JwtDecoder.isExpired(accessToken)) {
debugPrint('⏰ Access token expiré, tentative de rafraîchissement...');
final TokenResponse? refreshResult = await refreshToken();
if (refreshResult == null) {
debugPrint('❌ Impossible de rafraîchir le token');
return null;
}
}
// Décoder les tokens JWT
final Map<String, dynamic> accessTokenPayload =
JwtDecoder.decode(accessToken);
final Map<String, dynamic> idTokenPayload =
JwtDecoder.decode(idToken);
debugPrint('🔍 Payload Access Token: $accessTokenPayload');
debugPrint('🔍 Payload ID Token: $idTokenPayload');
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
final payload = JwtDecoder.decode(token);
final idPayload = JwtDecoder.decode(idToken);
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
debugPrint('🎭 Rôles Keycloak extraits: $keycloakRoles');
// Si aucun rôle, assigner un rôle par défaut
if (keycloakRoles.isEmpty) {
debugPrint('⚠️ Aucun rôle trouvé, assignation du rôle MEMBER par défaut');
keycloakRoles.add('member');
}
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
debugPrint('🎯 Rôle principal mappé: ${primaryRole.displayName}');
debugPrint('🔐 Permissions mappées: ${permissions.length} permissions');
debugPrint('📋 Permissions détaillées: $permissions');
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
final roles = _extractRoles(payload);
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
AppLogger.info('KeycloakAuthService: roles mapped', tag: '${primaryRole.name}');
return User(
id: idPayload['sub'] ?? '',
email: idPayload['email'] ?? '',
firstName: idPayload['given_name'] ?? '',
lastName: idPayload['family_name'] ?? '',
primaryRole: primaryRole,
organizationContexts: const [], // À implémenter selon vos besoins
additionalPermissions: permissions,
revokedPermissions: const [],
preferences: const UserPreferences(),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
isActive: true,
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur récupération utilisateur: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Déconnexion complète
static Future<bool> logout() async {
try {
debugPrint('🚪 Déconnexion Keycloak...');
final String? idToken = await _secureStorage.read(key: _idTokenKey);
// Déconnexion côté Keycloak si possible
if (idToken != null) {
try {
final EndSessionRequest request = EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: KeycloakConfig.redirectUrl,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
endSessionEndpoint: KeycloakConfig.logoutEndpoint,
),
);
await _appAuth.endSession(request);
debugPrint('✅ Déconnexion Keycloak côté serveur réussie');
} catch (e) {
debugPrint('⚠️ Déconnexion côté serveur échouée: $e');
// Continue quand même avec la déconnexion locale
}
}
// Nettoyage local des tokens
await _clearTokens();
debugPrint('✅ Déconnexion locale terminée');
return true;
} catch (e, stackTrace) {
debugPrint('💥 Erreur déconnexion: $e');
debugPrint('Stack trace: $stackTrace');
return false;
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
if (JwtDecoder.isExpired(accessToken)) {
// Tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult != null;
}
return true;
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(TokenResponse tokenResponse) async {
if (tokenResponse.accessToken != null) {
await _secureStorage.write(
key: _accessTokenKey,
value: tokenResponse.accessToken!,
);
}
if (tokenResponse.refreshToken != null) {
await _secureStorage.write(
key: _refreshTokenKey,
value: tokenResponse.refreshToken!,
);
}
if (tokenResponse.idToken != null) {
await _secureStorage.write(
key: _idTokenKey,
value: tokenResponse.idToken!,
);
}
debugPrint('🔒 Tokens stockés de manière sécurisée');
}
/// Nettoie tous les tokens stockés
static Future<void> _clearTokens() async {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
await _secureStorage.delete(key: _idTokenKey);
await _secureStorage.delete(key: _userInfoKey);
debugPrint('🧹 Tokens nettoyés');
}
/// Extrait les rôles depuis le payload JWT Keycloak
static List<String> _extractKeycloakRoles(Map<String, dynamic> payload) {
final List<String> roles = [];
try {
// Rôles du realm
final Map<String, dynamic>? realmAccess = payload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
final List<dynamic> realmRoles = realmAccess['roles'];
roles.addAll(realmRoles.cast<String>());
}
// Rôles des clients
final Map<String, dynamic>? resourceAccess = payload['resource_access'];
if (resourceAccess != null) {
resourceAccess.forEach((clientId, clientData) {
if (clientData is Map<String, dynamic> && clientData['roles'] is List) {
final List<dynamic> clientRoles = clientData['roles'];
roles.addAll(clientRoles.cast<String>());
}
});
}
// Filtrer les rôles système Keycloak
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Récupère le token d'accès actuel
static Future<String?> getAccessToken() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
return accessToken;
}
// Token expiré, tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult?.accessToken;
} catch (e) {
debugPrint('💥 Erreur récupération access token: $e');
return null;
} catch (e, st) {
AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st);
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES WEBVIEW - Délégation vers KeycloakWebViewAuthService
// ═══════════════════════════════════════════════════════════════════════════
/// Prépare l'authentification WebView
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareWebViewAuthentication() async {
return KeycloakWebViewAuthService.prepareAuthentication();
Future<void> logout() async {
await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: session cleared');
}
/// Traite le callback WebView et finalise l'authentification
///
/// Cette méthode doit être appelée quand l'URL de callback est interceptée
static Future<User> handleWebViewCallback(String callbackUrl) async {
return KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
Future<void> _saveTokens(Map<String, dynamic> data) async {
if (data['access_token'] != null) await _storage.write(key: _accessK, value: data['access_token']);
if (data['refresh_token'] != null) await _storage.write(key: _refreshK, value: data['refresh_token']);
if (data['id_token'] != null) await _storage.write(key: _idK, value: data['id_token']);
}
/// Vérifie si l'utilisateur est authentifié (compatible WebView)
static Future<bool> isWebViewAuthenticated() async {
return KeycloakWebViewAuthService.isAuthenticated();
List<String> _extractRoles(Map<String, dynamic> payload) {
final roles = <String>[];
if (payload['realm_access']?['roles'] != null) {
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
}
if (payload['resource_access'] != null) {
(payload['resource_access'] as Map).values.forEach((v) {
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
});
}
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
}
/// Récupère l'utilisateur authentifié (compatible WebView)
static Future<User?> getCurrentWebViewUser() async {
return KeycloakWebViewAuthService.getCurrentUser();
}
/// Déconnecte l'utilisateur (compatible WebView)
static Future<bool> logoutWebView() async {
return KeycloakWebViewAuthService.logout();
}
/// Nettoie les données d'authentification WebView
static Future<void> clearWebViewAuthData() async {
return KeycloakWebViewAuthService.clearAuthData();
Future<String?> getValidToken() async {
final token = await _storage.read(key: _accessK);
if (token != null && !JwtDecoder.isExpired(token)) return token;
return await refreshToken();
}
}

View File

@@ -13,6 +13,7 @@ class KeycloakRoleMapper {
// Rôles administratifs
'SUPER_ADMINISTRATEUR': UserRole.superAdmin,
'ADMIN': UserRole.superAdmin,
'ADMIN_ORGANISATION': UserRole.orgAdmin, // Rôle Keycloak (backend)
'ADMINISTRATEUR_ORGANISATION': UserRole.orgAdmin,
'PRESIDENT': UserRole.orgAdmin,
@@ -23,6 +24,9 @@ class KeycloakRoleMapper {
'SECRETAIRE': UserRole.moderator,
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
'CONSULTANT': UserRole.consultant,
'GESTIONNAIRE_RH': UserRole.hrManager,
'HR_MANAGER': UserRole.hrManager,
// Rôles membres
'MEMBRE_ACTIF': UserRole.activeMember,
@@ -72,6 +76,21 @@ class KeycloakRoleMapper {
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'ADMIN_ORGANISATION': [
// Permissions Admin Organisation (rôle Keycloak backend)
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'ADMINISTRATEUR_ORGANISATION': [
// Permissions Admin Organisation
PermissionMatrix.ORG_CONFIG,
@@ -172,6 +191,33 @@ class KeycloakRoleMapper {
PermissionMatrix.COMM_SEND_MEMBERS,
],
'CONSULTANT': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
],
'GESTIONNAIRE_RH': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
],
'HR_MANAGER': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
],
'MEMBRE_ACTIF': [
// Permissions Membre Actif
PermissionMatrix.MEMBERS_VIEW_OWN,
@@ -214,10 +260,14 @@ class KeycloakRoleMapper {
/// Mappe une liste de rôles Keycloak vers le UserRole principal
static UserRole mapToUserRole(List<String> keycloakRoles) {
// Normaliser en majuscules pour éviter les écarts de casse (ex. admin_organisation)
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
// Priorité des rôles (du plus élevé au plus bas)
const List<String> rolePriority = [
'SUPER_ADMINISTRATEUR',
'ADMIN',
'ADMIN_ORGANISATION',
'ADMINISTRATEUR_ORGANISATION',
'PRESIDENT',
'RESPONSABLE_TECHNIQUE',
@@ -226,18 +276,21 @@ class KeycloakRoleMapper {
'SECRETAIRE',
'GESTIONNAIRE_MEMBRE',
'ORGANISATEUR_EVENEMENT',
'CONSULTANT',
'GESTIONNAIRE_RH',
'HR_MANAGER',
'MEMBRE_ACTIF',
'MEMBRE_SIMPLE',
'MEMBRE',
];
// Trouver le rôle avec la priorité la plus élevée
for (final String priorityRole in rolePriority) {
if (keycloakRoles.contains(priorityRole)) {
if (normalized.contains(priorityRole)) {
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
}
}
// Par défaut, visiteur si aucun rôle reconnu
return UserRole.visitor;
}
@@ -245,9 +298,12 @@ class KeycloakRoleMapper {
/// Mappe une liste de rôles Keycloak vers les permissions
static List<String> mapToPermissions(List<String> keycloakRoles) {
final Set<String> permissions = <String>{};
// Normaliser en majuscules pour cohérence avec le mapping
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
// Ajouter les permissions pour chaque rôle
for (final String role in keycloakRoles) {
for (final String role in normalized) {
final List<String>? rolePermissions = _keycloakToPermissions[role];
if (rolePermissions != null) {
permissions.addAll(rolePermissions);

View File

@@ -530,6 +530,7 @@ class KeycloakWebViewAuthService {
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
debugPrint('🔐 [AUTH WebView] Rôles: $keycloakRoles → UserRole: ${primaryRole.name} (${primaryRole.displayName})');
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
// Créer l'utilisateur

View File

@@ -239,14 +239,15 @@ class PermissionEngine {
return _checkContextualPermissions(user, permission, organizationId);
}
/// Vérifications contextuelles avancées
/// Vérifications contextuelles avancées (intégration serveur).
/// Quand le backend exposera GET /api/permissions/check avec userId, permission, organizationId,
/// remplacer le return false par l'appel API et le résultat.
static Future<bool> _checkContextualPermissions(
User user,
String permission,
String? organizationId,
) async {
// Logique contextuelle future (intégration avec le serveur)
// Pour l'instant, retourne false
// Vérification contextuelle désactivée — endpoint non disponible.
return false;
}

View File

@@ -39,6 +39,26 @@ enum UserRole {
permissions: _moderatorPermissions,
),
/// Consultant - Niveau intermédiaire (58)
/// Accès consultant / conseil
consultant(
level: 58,
displayName: 'Consultant',
description: 'Accès consultant et conseil',
color: 0xFF6C5CE7, // Violet
permissions: _consultantPermissions,
),
/// Gestionnaire RH - Niveau intermédiaire (52)
/// Gestion des ressources humaines
hrManager(
level: 52,
displayName: 'Gestionnaire RH',
description: 'Gestion des ressources humaines',
color: 0xFF0984E3, // Bleu
permissions: _hrManagerPermissions,
),
/// Membre Actif - Niveau utilisateur (40)
/// Accès aux fonctionnalités membres avec participation active
activeMember(
@@ -271,6 +291,26 @@ const List<String> _moderatorPermissions = [
PermissionMatrix.COMM_SEND_MEMBERS,
];
/// Permissions du Consultant
const List<String> _consultantPermissions = [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
];
/// Permissions du Gestionnaire RH
const List<String> _hrManagerPermissions = [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
];
/// Permissions du Membre Actif
const List<String> _activeMemberPermissions = [
// Dashboard personnel

View File

@@ -1,468 +1,139 @@
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
library auth_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import '../../data/models/user.dart';
import '../../data/models/user_role.dart';
import '../../data/datasources/permission_engine.dart';
import '../../data/datasources/keycloak_auth_service.dart';
import '../../data/datasources/dashboard_cache_manager.dart';
import '../../data/datasources/permission_engine.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
// === ÉVÉNEMENTS ===
/// Événements d'authentification
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
/// Événement de connexion Keycloak
class AuthLoginRequested extends AuthEvent {
const AuthLoginRequested();
}
/// Événement de déconnexion
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
/// Événement de changement de contexte organisationnel
class AuthOrganizationContextChanged extends AuthEvent {
final String organizationId;
const AuthOrganizationContextChanged(this.organizationId);
final String email;
final String password;
const AuthLoginRequested(this.email, this.password);
@override
List<Object?> get props => [organizationId];
List<Object?> get props => [email, password];
}
/// Événement de rafraîchissement du token
class AuthTokenRefreshRequested extends AuthEvent {
const AuthTokenRefreshRequested();
}
/// Événement de vérification de l'état d'authentification
class AuthStatusChecked extends AuthEvent {
const AuthStatusChecked();
}
/// Événement de mise à jour du profil utilisateur
class AuthUserProfileUpdated extends AuthEvent {
final User updatedUser;
const AuthUserProfileUpdated(this.updatedUser);
@override
List<Object?> get props => [updatedUser];
}
/// Événement de callback WebView
class AuthWebViewCallback extends AuthEvent {
final String callbackUrl;
final User? user;
const AuthWebViewCallback(this.callbackUrl, {this.user});
@override
List<Object?> get props => [callbackUrl, user];
}
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
class AuthStatusChecked extends AuthEvent { const AuthStatusChecked(); }
class AuthTokenRefreshRequested extends AuthEvent { const AuthTokenRefreshRequested(); }
// === ÉTATS ===
/// États d'authentification
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
/// État initial
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthUnauthenticated extends AuthState {}
/// État de chargement
class AuthLoading extends AuthState {
const AuthLoading();
}
/// État authentifié avec contexte riche
class AuthAuthenticated extends AuthState {
final User user;
final String? currentOrganizationId;
final UserRole effectiveRole;
final List<String> effectivePermissions;
final DateTime authenticatedAt;
final String? accessToken;
final String accessToken;
const AuthAuthenticated({
required this.user,
this.currentOrganizationId,
required this.effectiveRole,
required this.effectivePermissions,
required this.authenticatedAt,
this.accessToken,
required this.accessToken,
});
/// Vérifie si l'utilisateur a une permission
bool hasPermission(String permission) {
return effectivePermissions.contains(permission);
}
/// Vérifie si l'utilisateur peut effectuer une action
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
final permission = '$domain.$action.$scope';
return hasPermission(permission);
}
/// Obtient le contexte organisationnel actuel
UserOrganizationContext? get currentOrganizationContext {
if (currentOrganizationId == null) return null;
return user.getOrganizationContext(currentOrganizationId!);
}
/// Crée une copie avec des modifications
AuthAuthenticated copyWith({
User? user,
String? currentOrganizationId,
UserRole? effectiveRole,
List<String>? effectivePermissions,
DateTime? authenticatedAt,
String? accessToken,
}) {
return AuthAuthenticated(
user: user ?? this.user,
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
effectiveRole: effectiveRole ?? this.effectiveRole,
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
accessToken: accessToken ?? this.accessToken,
);
}
@override
List<Object?> get props => [
user,
currentOrganizationId,
effectiveRole,
effectivePermissions,
authenticatedAt,
accessToken,
];
List<Object?> get props => [user, effectiveRole, effectivePermissions, accessToken];
}
/// État non authentifié
class AuthUnauthenticated extends AuthState {
final String? message;
const AuthUnauthenticated({this.message});
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object?> get props => [message];
}
/// État d'erreur
class AuthError extends AuthState {
final String message;
final String? errorCode;
const AuthError({
required this.message,
this.errorCode,
});
@override
List<Object?> get props => [message, errorCode];
}
/// État indiquant qu'une WebView d'authentification est requise
class AuthWebViewRequired extends AuthState {
final String authUrl;
final String state;
final String codeVerifier;
const AuthWebViewRequired({
required this.authUrl,
required this.state,
required this.codeVerifier,
});
@override
List<Object?> get props => [authUrl, state, codeVerifier];
}
// === BLOC ===
/// BLoC d'authentification adaptatif
@lazySingleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(const AuthInitial()) {
final KeycloakAuthService _authService;
AuthBloc(this._authService) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
on<AuthWebViewCallback>(_onWebViewCallback);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
}
/// Gère la demande de connexion Keycloak via WebView
///
/// Cette méthode prépare l'authentification WebView et émet un état spécial
/// pour indiquer qu'une WebView doit être ouverte
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
debugPrint('🔐 Préparation authentification Keycloak WebView...');
// Préparer l'authentification WebView
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
debugPrint('✅ Authentification WebView préparée');
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
emit(AuthWebViewRequired(
authUrl: authParams['url']!,
state: authParams['state']!,
codeVerifier: authParams['code_verifier']!,
));
debugPrint('✅ État AuthWebViewRequired émis');
} catch (e, stackTrace) {
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de préparation: $e'));
}
}
/// Traite le callback WebView et finalise l'authentification
Future<void> _onWebViewCallback(
AuthWebViewCallback event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
try {
debugPrint('🔄 Traitement callback WebView...');
// Utiliser l'utilisateur fourni ou traiter le callback
final User user;
if (event.user != null) {
debugPrint('👤 Utilisation des données utilisateur fournies: ${event.user!.fullName}');
user = event.user!;
final user = await _authService.login(event.email, event.password);
if (user != null) {
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
await DashboardCacheManager.invalidateForRole(user.primaryRole);
emit(AuthAuthenticated(
user: user,
effectiveRole: user.primaryRole,
effectivePermissions: permissions,
accessToken: token ?? '',
));
} else {
debugPrint('🔄 Traitement du callback URL: ${event.callbackUrl}');
user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
emit(const AuthError('Identifiants incorrects.'));
}
debugPrint('👤 Utilisateur authentifié: ${user.fullName} (${user.primaryRole.displayName})');
// Calculer les permissions effectives
debugPrint('🔐 Calcul des permissions effectives...');
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
// Invalider le cache pour forcer le rechargement
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
await DashboardCacheManager.invalidateForRole(user.primaryRole);
debugPrint('✅ Cache invalidé');
emit(AuthAuthenticated(
user: user,
currentOrganizationId: null, // À implémenter selon vos besoins
effectiveRole: user.primaryRole,
effectivePermissions: effectivePermissions,
authenticatedAt: DateTime.now(),
accessToken: '', // Token géré par KeycloakWebViewAuthService
));
debugPrint('🎉 Authentification complète réussie - navigation vers dashboard');
} catch (e, stackTrace) {
debugPrint('💥 Erreur authentification: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de connexion: $e'));
}
}
/// Gère la demande de déconnexion Keycloak
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
try {
debugPrint('🚪 Démarrage déconnexion Keycloak...');
// Déconnexion Keycloak
final logoutSuccess = await KeycloakAuthService.logout();
if (!logoutSuccess) {
debugPrint('⚠️ Déconnexion Keycloak partielle');
}
// Nettoyer le cache local
await DashboardCacheManager.clear();
// Invalider le cache des permissions
if (state is AuthAuthenticated) {
final authState = state as AuthAuthenticated;
PermissionEngine.invalidateUserCache(authState.user.id);
}
debugPrint('✅ Déconnexion complète réussie');
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
} catch (e, stackTrace) {
debugPrint('💥 Erreur déconnexion: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de déconnexion: $e'));
}
}
/// Gère le changement de contexte organisationnel
Future<void> _onOrganizationContextChanged(
AuthOrganizationContextChanged event,
Emitter<AuthState> emit,
) async {
if (state is! AuthAuthenticated) return;
final currentState = state as AuthAuthenticated;
emit(const AuthLoading());
try {
// Recalculer le rôle effectif et les permissions
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
currentState.user,
organizationId: event.organizationId,
);
// Invalider le cache pour le nouveau contexte
PermissionEngine.invalidateUserCache(currentState.user.id);
emit(currentState.copyWith(
currentOrganizationId: event.organizationId,
effectiveRole: effectiveRole,
effectivePermissions: effectivePermissions,
));
} catch (e) {
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
emit(AuthError('Erreur de connexion: $e'));
}
}
/// Gère le rafraîchissement du token
Future<void> _onTokenRefreshRequested(
AuthTokenRefreshRequested event,
Emitter<AuthState> emit,
) async {
if (state is! AuthAuthenticated) return;
final currentState = state as AuthAuthenticated;
try {
// Simulation du rafraîchissement (à remplacer par l'API réelle)
await Future.delayed(const Duration(seconds: 1));
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
emit(currentState.copyWith(accessToken: newToken));
} catch (e) {
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
}
Future<void> _onLogoutRequested(AuthLogoutRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _authService.logout();
await DashboardCacheManager.clear();
emit(AuthUnauthenticated());
}
/// Vérifie l'état d'authentification Keycloak
Future<void> _onStatusChecked(
AuthStatusChecked event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
final tokenValid = await _authService.getValidToken();
final isAuth = tokenValid != null;
if (!isAuth) {
emit(AuthUnauthenticated());
return;
}
final user = await _authService.getCurrentUser();
if (user == null) {
emit(AuthUnauthenticated());
return;
}
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
emit(AuthAuthenticated(
user: user,
effectiveRole: user.primaryRole,
effectivePermissions: permissions,
accessToken: token ?? '',
));
}
try {
debugPrint('🔍 Vérification état authentification Keycloak...');
// Vérifier si l'utilisateur est authentifié avec Keycloak
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
if (!isAuthenticated) {
debugPrint('❌ Utilisateur non authentifié');
emit(const AuthUnauthenticated());
return;
Future<void> _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter<AuthState> emit) async {
if (state is AuthAuthenticated) {
final newToken = await _authService.refreshToken();
final success = newToken != null;
if (success) {
add(AuthStatusChecked());
} else {
add(AuthLogoutRequested());
}
// Récupérer l'utilisateur actuel
final User? user = await KeycloakAuthService.getCurrentUser();
if (user == null) {
debugPrint('❌ Impossible de récupérer l\'utilisateur');
emit(const AuthUnauthenticated());
return;
}
// Calculer les permissions effectives
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
// Récupérer le token d'accès
final String? accessToken = await KeycloakAuthService.getAccessToken();
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
emit(AuthAuthenticated(
user: user,
currentOrganizationId: null, // À implémenter selon vos besoins
effectiveRole: user.primaryRole,
effectivePermissions: effectivePermissions,
authenticatedAt: DateTime.now(),
accessToken: accessToken ?? '',
));
} catch (e, stackTrace) {
debugPrint('💥 Erreur vérification authentification: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de vérification: $e'));
}
}
/// Met à jour le profil utilisateur
Future<void> _onUserProfileUpdated(
AuthUserProfileUpdated event,
Emitter<AuthState> emit,
) async {
if (state is! AuthAuthenticated) return;
final currentState = state as AuthAuthenticated;
try {
// Recalculer les permissions si nécessaire
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
event.updatedUser,
organizationId: currentState.currentOrganizationId,
);
emit(currentState.copyWith(
user: event.updatedUser,
effectivePermissions: effectivePermissions,
));
} catch (e) {
emit(AuthError(message: 'Erreur de mise à jour: $e'));
}
}
}

View File

@@ -1,160 +1,68 @@
/// Page de Connexion UnionFlow - Design System Unifié (Version Premium)
/// Interface de connexion moderne orientée métier avec animations avancées
/// Utilise la palette Bleu Roi (#4169E1) + Bleu Pétrole (#2C5F6F)
library login_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/auth_bloc.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import 'keycloak_webview_auth_page.dart';
import 'package:url_launcher/url_launcher.dart';
/// Page de connexion UnionFlow
/// Présente l'application et permet l'authentification sécurisée
import '../bloc/auth_bloc.dart';
import '../../../../core/config/environment.dart';
import '../../../../shared/widgets/core_text_field.dart';
import '../../../../shared/widgets/dynamic_fab.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste)
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
const LoginPage({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage>
with TickerProviderStateMixin {
late AnimationController _animationController;
late AnimationController _backgroundController;
late AnimationController _pulseController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _backgroundAnimation;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_initializeAnimations();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_animationController.dispose();
_backgroundController.dispose();
_pulseController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _initializeAnimations() {
// Animation principale d'entrée
_animationController = AnimationController(
duration: const Duration(milliseconds: 1400),
vsync: this,
Future<void> _openForgotPassword(BuildContext context) async {
final url = Uri.parse(
'${AppConfig.keycloakRealmUrl}/protocol/openid-connect/auth'
'?client_id=unionflow-mobile'
'&redirect_uri=${Uri.encodeComponent('http://localhost')}'
'&response_type=code'
'&scope=openid'
'&kc_action=reset_credentials',
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.4),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
));
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.6, curve: Curves.easeOutBack),
));
// Animation de fond subtile
_backgroundController = AnimationController(
duration: const Duration(seconds: 8),
vsync: this,
)..repeat(reverse: true);
_backgroundAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _backgroundController,
curve: Curves.easeInOut,
));
// Animation de pulsation pour le logo
_pulseController = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.08,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_animationController.forward();
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')),
);
}
}
}
/// Ouvre la page WebView d'authentification
void _openWebViewAuth(BuildContext context, AuthWebViewRequired state) {
debugPrint('🚀 Ouverture WebView avec URL: ${state.authUrl}');
debugPrint('🔑 State: ${state.state}');
debugPrint('🔐 Code verifier: ${state.codeVerifier.substring(0, 10)}...');
void _onLogin() {
final email = _emailController.text;
final password = _passwordController.text;
debugPrint('📱 Tentative de navigation vers KeycloakWebViewAuthPage...');
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => KeycloakWebViewAuthPage(
onAuthSuccess: (user) {
debugPrint('✅ Authentification réussie pour: ${user.fullName}');
debugPrint('🔄 Notification du BLoC avec les données utilisateur...');
context.read<AuthBloc>().add(AuthWebViewCallback(
'success',
user: user,
));
Navigator.of(context).pop();
},
onAuthError: (error) {
debugPrint('❌ Erreur d\'authentification: $error');
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur d\'authentification: $error'),
backgroundColor: ColorTokens.error,
duration: const Duration(seconds: 5),
behavior: SnackBarBehavior.floating,
),
);
},
onAuthCancel: () {
debugPrint('❌ Authentification annulée par l\'utilisateur');
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Authentification annulée'),
backgroundColor: ColorTokens.warning,
behavior: SnackBarBehavior.floating,
),
);
},
),
),
);
debugPrint('✅ Navigation vers KeycloakWebViewAuthPage lancée');
if (email.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
}
}
@override
@@ -162,577 +70,100 @@ class _LoginPageState extends State<LoginPage>
return Scaffold(
body: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
debugPrint('🔄 État BLoC reçu: ${state.runtimeType}');
if (state is AuthAuthenticated) {
debugPrint('✅ Utilisateur authentifié, navigation vers dashboard');
Navigator.of(context).pushReplacementNamed('/dashboard');
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
} else if (state is AuthError) {
debugPrint('❌ Erreur d\'authentification: ${state.message}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: ColorTokens.error,
behavior: SnackBarBehavior.floating,
content: Text(state.message, style: AppTypography.bodyTextSmall),
backgroundColor: AppColors.error,
),
);
} else if (state is AuthWebViewRequired) {
debugPrint('🚀 État AuthWebViewRequired reçu, ouverture WebView...');
WidgetsBinding.instance.addPostFrameCallback((_) {
_openWebViewAuth(context, state);
});
} else if (state is AuthLoading) {
debugPrint('⏳ État de chargement...');
} else {
debugPrint(' État non géré: ${state.runtimeType}');
}
},
builder: (context, state) {
if (state is AuthWebViewRequired) {
debugPrint('🔄 Builder détecte AuthWebViewRequired, ouverture WebView...');
WidgetsBinding.instance.addPostFrameCallback((_) {
_openWebViewAuth(context, state);
});
}
final isLoading = state is AuthLoading;
return _buildLoginContent(context, state);
},
),
);
}
return SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo minimaliste (Texte seul)
Center(
child: Text(
'UnionFlow',
style: AppTypography.headerSmall.copyWith(
fontSize: 24, // Exception unique pour le logo
color: AppColors.primaryGreen,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Connexion à votre espace.',
style: AppTypography.subtitleSmall,
),
),
const SizedBox(height: 48),
Widget _buildLoginContent(BuildContext context, AuthState state) {
return Stack(
children: [
// Fond animé avec dégradé dynamique
AnimatedBuilder(
animation: _backgroundAnimation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
ColorTokens.background,
Color.lerp(
ColorTokens.background,
ColorTokens.surface,
_backgroundAnimation.value * 0.3,
)!,
ColorTokens.surface,
// Champs de texte DRY
CoreTextField(
controller: _emailController,
hintText: 'Email ou Identifiant',
prefixIcon: Icons.person_outline,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
CoreTextField(
controller: _passwordController,
hintText: 'Mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: true,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _openForgotPassword(context),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Oublié ?',
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.primaryGreen,
),
),
),
),
const SizedBox(height: 32),
// Bouton centralisé avec chargement intégré
Center(
child: isLoading
? const CircularProgressIndicator(color: AppColors.primaryGreen)
: DynamicFAB(
icon: Icons.arrow_forward,
label: 'Se Connecter',
onPressed: _onLogin,
),
),
],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
),
// Éléments décoratifs de fond
_buildBackgroundDecoration(),
// Contenu principal
SafeArea(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: _buildLoginUI(),
),
);
},
),
),
],
);
}
Widget _buildBackgroundDecoration() {
return Positioned.fill(
child: AnimatedBuilder(
animation: _backgroundAnimation,
builder: (context, child) {
return Stack(
children: [
// Cercle décoratif haut gauche
Positioned(
top: -100 + (_backgroundAnimation.value * 30),
left: -100 + (_backgroundAnimation.value * 20),
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
ColorTokens.primary.withOpacity(0.15),
ColorTokens.primary.withOpacity(0.0),
],
),
),
),
),
// Cercle décoratif bas droit
Positioned(
bottom: -150 - (_backgroundAnimation.value * 30),
right: -120 - (_backgroundAnimation.value * 20),
child: Container(
width: 400,
height: 400,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
ColorTokens.primary.withOpacity(0.12),
ColorTokens.primary.withOpacity(0.0),
],
),
),
),
),
// Cercle décoratif centre
Positioned(
top: MediaQuery.of(context).size.height * 0.3,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
ColorTokens.secondary.withOpacity(0.1),
ColorTokens.secondary.withOpacity(0.0),
],
),
),
),
),
],
),
);
},
),
);
}
Widget _buildLoginUI() {
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xxxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: SpacingTokens.giant),
// Logo et branding premium
_buildBranding(),
const SizedBox(height: SpacingTokens.giant),
// Features cards
_buildFeatureCards(),
const SizedBox(height: SpacingTokens.giant),
// Card de connexion principale
_buildLoginCard(),
const SizedBox(height: SpacingTokens.xxxl),
// Footer amélioré
_buildFooter(),
const SizedBox(height: SpacingTokens.giant),
],
),
),
),
),
);
}
Widget _buildBranding() {
return ScaleTransition(
scale: _scaleAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo animé avec effet de pulsation
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: _pulseAnimation.value,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: ColorTokens.primaryGradient,
),
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
boxShadow: [
BoxShadow(
color: ColorTokens.primary.withOpacity(0.3),
blurRadius: 24,
offset: const Offset(0, 10),
spreadRadius: 2,
),
],
),
child: const Icon(
Icons.account_balance_outlined,
size: 32,
color: ColorTokens.onPrimary,
),
),
);
},
),
const SizedBox(height: SpacingTokens.xxxl),
// Titre avec gradient
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: ColorTokens.primaryGradient,
).createShader(bounds),
child: Text(
'Bienvenue',
style: TypographyTokens.displaySmall.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: -1,
height: 1.1,
),
),
),
const SizedBox(height: SpacingTokens.md),
// Sous-titre élégant
Text(
'Connectez-vous à votre espace UnionFlow',
style: TypographyTokens.bodyLarge.copyWith(
color: ColorTokens.onSurfaceVariant,
fontWeight: FontWeight.w400,
height: 1.5,
letterSpacing: 0.2,
),
),
],
),
);
}
Widget _buildFeatureCards() {
final features = [
{
'icon': Icons.account_balance_wallet_rounded,
'title': 'Cotisations',
'color': ColorTokens.primary,
},
{
'icon': Icons.event_rounded,
'title': 'Événements',
'color': ColorTokens.secondary,
},
{
'icon': Icons.volunteer_activism_rounded,
'title': 'Solidarité',
'color': ColorTokens.primary,
},
];
return Row(
children: features.map((feature) {
final index = features.indexOf(feature);
return Expanded(
child: Padding(
padding: EdgeInsets.only(
right: index < features.length - 1 ? SpacingTokens.md : 0,
),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 600 + (index * 150)),
curve: Curves.easeOutBack,
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: SpacingTokens.lg,
horizontal: SpacingTokens.sm,
),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
border: Border.all(
color: (feature['color'] as Color).withOpacity(0.15),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: ColorTokens.shadow.withOpacity(0.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(SpacingTokens.sm),
decoration: BoxDecoration(
color: (feature['color'] as Color).withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
child: Icon(
feature['icon'] as IconData,
size: 24,
color: feature['color'] as Color,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
feature['title'] as String,
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
),
);
},
),
),
);
}).toList(),
);
}
Widget _buildLoginCard() {
return Container(
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(SpacingTokens.radiusXxl),
border: Border.all(
color: ColorTokens.outline.withOpacity(0.08),
width: 1,
),
boxShadow: [
BoxShadow(
color: ColorTokens.shadow.withOpacity(0.1),
blurRadius: 32,
offset: const Offset(0, 12),
spreadRadius: -4,
),
],
),
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.huge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Titre de la card
Row(
children: [
Container(
padding: const EdgeInsets.all(SpacingTokens.xs),
decoration: BoxDecoration(
color: ColorTokens.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: const Icon(
Icons.fingerprint_rounded,
size: 20,
color: ColorTokens.primary,
),
),
const SizedBox(width: SpacingTokens.md),
Text(
'Authentification',
style: TypographyTokens.titleMedium.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: SpacingTokens.xxl),
// Bouton de connexion principal
_buildLoginButton(),
const SizedBox(height: SpacingTokens.xxl),
// Divider avec texte
Row(
children: [
Expanded(
child: Container(
height: 1,
color: ColorTokens.outline.withOpacity(0.1),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md),
child: Text(
'Sécurisé',
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Container(
height: 1,
color: ColorTokens.outline.withOpacity(0.1),
),
),
],
),
const SizedBox(height: SpacingTokens.xxl),
// Informations de sécurité améliorées
Container(
padding: const EdgeInsets.all(SpacingTokens.lg),
decoration: BoxDecoration(
color: ColorTokens.primary.withOpacity(0.05),
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(
color: ColorTokens.primary.withOpacity(0.1),
width: 1,
),
),
child: Row(
children: [
const Icon(
Icons.verified_user_rounded,
size: 20,
color: ColorTokens.primary,
),
const SizedBox(width: SpacingTokens.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connexion sécurisée',
style: TypographyTokens.labelMedium.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xs),
Text(
'Vos données sont protégées et chiffrées',
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildFooter() {
return Column(
children: [
// Aide
Container(
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.lg,
vertical: SpacingTokens.md,
),
decoration: BoxDecoration(
color: ColorTokens.surface.withOpacity(0.5),
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
border: Border.all(
color: ColorTokens.outline.withOpacity(0.08),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.help_outline_rounded,
size: 18,
color: ColorTokens.onSurfaceVariant.withOpacity(0.7),
),
const SizedBox(width: SpacingTokens.sm),
Text(
'Besoin d\'aide ?',
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant.withOpacity(0.8),
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: SpacingTokens.xl),
// Copyright
Text(
'© 2025 UnionFlow. Tous droits réservés.',
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant.withOpacity(0.5),
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.xs),
Text(
'Version 1.0.0',
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
fontWeight: FontWeight.w500,
fontSize: 11,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildLoginButton() {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final isLoading = state is AuthLoading;
return UFPrimaryButton(
label: 'Se connecter',
icon: Icons.login_rounded,
onPressed: isLoading ? null : _handleLogin,
isLoading: isLoading,
isFullWidth: true,
height: 56,
);
},
);
}
void _handleLogin() {
// Démarrer l'authentification Keycloak
context.read<AuthBloc>().add(const AuthLoginRequested());
}
}

View File

@@ -0,0 +1,63 @@
/// Modèle de configuration des sauvegardes
/// Correspond à BackupConfigResponse du backend
library backup_config_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_config_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupConfigModel extends Equatable {
final bool? autoBackupEnabled;
final String? frequency; // HOURLY, DAILY, WEEKLY
final String? retention;
final int? retentionDays;
final String? backupTime;
final bool? includeDatabase;
final bool? includeFiles;
final bool? includeConfiguration;
final DateTime? lastBackup;
final DateTime? nextScheduledBackup;
final int? totalBackups;
final int? totalSizeBytes;
final String? totalSizeFormatted;
const BackupConfigModel({
this.autoBackupEnabled,
this.frequency,
this.retention,
this.retentionDays,
this.backupTime,
this.includeDatabase,
this.includeFiles,
this.includeConfiguration,
this.lastBackup,
this.nextScheduledBackup,
this.totalBackups,
this.totalSizeBytes,
this.totalSizeFormatted,
});
factory BackupConfigModel.fromJson(Map<String, dynamic> json) =>
_$BackupConfigModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupConfigModelToJson(this);
@override
List<Object?> get props => [
autoBackupEnabled,
frequency,
retention,
retentionDays,
backupTime,
includeDatabase,
includeFiles,
includeConfiguration,
lastBackup,
nextScheduledBackup,
totalBackups,
totalSizeBytes,
totalSizeFormatted,
];
}

View File

@@ -0,0 +1,69 @@
/// Modèle de sauvegarde
/// Correspond à BackupResponse du backend
library backup_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupModel extends Equatable {
final String? id;
final String? name;
final String? description;
final String? type; // AUTO, MANUAL, RESTORE_POINT
final int? sizeBytes;
final String? sizeFormatted;
final String? status; // PENDING, IN_PROGRESS, COMPLETED, FAILED
final DateTime? createdAt;
final DateTime? completedAt;
final String? createdBy;
final bool? includesDatabase;
final bool? includesFiles;
final bool? includesConfiguration;
final String? filePath;
final String? errorMessage;
const BackupModel({
this.id,
this.name,
this.description,
this.type,
this.sizeBytes,
this.sizeFormatted,
this.status,
this.createdAt,
this.completedAt,
this.createdBy,
this.includesDatabase,
this.includesFiles,
this.includesConfiguration,
this.filePath,
this.errorMessage,
});
factory BackupModel.fromJson(Map<String, dynamic> json) =>
_$BackupModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupModelToJson(this);
@override
List<Object?> get props => [
id,
name,
description,
type,
sizeBytes,
sizeFormatted,
status,
createdAt,
completedAt,
createdBy,
includesDatabase,
includesFiles,
includesConfiguration,
filePath,
errorMessage,
];
}

View File

@@ -0,0 +1,131 @@
/// Repository pour la gestion des sauvegardes
/// Interface avec l'API backend BackupResource
library backup_repository;
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/backup_model.dart';
import '../models/backup_config_model.dart';
abstract class BackupRepository {
Future<List<BackupModel>> getAll();
Future<BackupModel> getById(String id);
Future<BackupModel> create(String name, {String? description});
Future<void> restore(String backupId, {bool createRestorePoint = true});
Future<void> delete(String id);
Future<BackupConfigModel> getConfig();
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config);
Future<BackupModel> createRestorePoint();
}
@LazySingleton(as: BackupRepository)
class BackupRepositoryImpl implements BackupRepository {
final ApiClient _apiClient;
static const String _base = '/api/backups';
BackupRepositoryImpl(this._apiClient);
List<BackupModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<BackupModel>> getAll() async {
final response = await _apiClient.get(_base);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> create(String name, {String? description}) async {
final response = await _apiClient.post(
_base,
data: {
'name': name,
'description': description,
'type': 'MANUAL',
'includeDatabase': true,
'includeFiles': true,
'includeConfiguration': true,
},
);
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<void> restore(String backupId, {bool createRestorePoint = true}) async {
final response = await _apiClient.post(
'$_base/restore',
data: {
'backupId': backupId,
'restoreDatabase': true,
'restoreFiles': true,
'restoreConfiguration': true,
'createRestorePoint': createRestorePoint,
},
);
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<void> delete(String id) async {
final response = await _apiClient.delete('$_base/$id');
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<BackupConfigModel> getConfig() async {
final response = await _apiClient.get('$_base/config');
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config) async {
final response = await _apiClient.put('$_base/config', data: config);
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> createRestorePoint() async {
final response = await _apiClient.post('$_base/restore-point');
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
}

View File

@@ -0,0 +1,166 @@
/// BLoC pour la gestion des sauvegardes
library backup_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:equatable/equatable.dart';
import '../../data/repositories/backup_repository.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
// Events
abstract class BackupEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadBackups extends BackupEvent {}
class CreateBackup extends BackupEvent {
final String name;
final String? description;
CreateBackup(this.name, {this.description});
@override
List<Object?> get props => [name, description];
}
class RestoreBackup extends BackupEvent {
final String backupId;
RestoreBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class DeleteBackup extends BackupEvent {
final String backupId;
DeleteBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class LoadBackupConfig extends BackupEvent {}
class UpdateBackupConfig extends BackupEvent {
final Map<String, dynamic> config;
UpdateBackupConfig(this.config);
@override
List<Object?> get props => [config];
}
// States
abstract class BackupState extends Equatable {
@override
List<Object?> get props => [];
}
class BackupInitial extends BackupState {}
class BackupLoading extends BackupState {}
class BackupsLoaded extends BackupState {
final List<BackupModel> backups;
BackupsLoaded(this.backups);
@override
List<Object?> get props => [backups];
}
class BackupConfigLoaded extends BackupState {
final BackupConfigModel config;
BackupConfigLoaded(this.config);
@override
List<Object?> get props => [config];
}
class BackupSuccess extends BackupState {
final String message;
BackupSuccess(this.message);
@override
List<Object?> get props => [message];
}
class BackupError extends BackupState {
final String error;
BackupError(this.error);
@override
List<Object?> get props => [error];
}
// Bloc
@injectable
class BackupBloc extends Bloc<BackupEvent, BackupState> {
final BackupRepository _repository;
BackupBloc(this._repository) : super(BackupInitial()) {
on<LoadBackups>(_onLoadBackups);
on<CreateBackup>(_onCreateBackup);
on<RestoreBackup>(_onRestoreBackup);
on<DeleteBackup>(_onDeleteBackup);
on<LoadBackupConfig>(_onLoadBackupConfig);
on<UpdateBackupConfig>(_onUpdateBackupConfig);
}
Future<void> _onLoadBackups(LoadBackups event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onCreateBackup(CreateBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.create(event.name, description: event.description);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde créée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onRestoreBackup(RestoreBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.restore(event.backupId);
emit(BackupSuccess('Restauration en cours'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onDeleteBackup(DeleteBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.delete(event.backupId);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde supprimée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onLoadBackupConfig(LoadBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.getConfig();
emit(BackupConfigLoaded(config));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onUpdateBackupConfig(UpdateBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.updateConfig(event.config);
emit(BackupConfigLoaded(config));
emit(BackupSuccess('Configuration mise à jour'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
}

View File

@@ -1,6 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/utils/logger.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
import '../../data/repositories/backup_repository.dart';
import '../bloc/backup_bloc.dart';
/// Page Sauvegarde & Restauration - UnionFlow Mobile
///
@@ -21,6 +30,9 @@ class _BackupPageState extends State<BackupPage>
String _selectedFrequency = 'Quotidien';
String _selectedRetention = '30 jours';
List<BackupModel>? _cachedBackups;
BackupConfigModel? _cachedConfig;
final List<String> _frequencies = ['Horaire', 'Quotidien', 'Hebdomadaire'];
final List<String> _retentions = ['7 jours', '30 jours', '90 jours', '1 an'];
@@ -38,23 +50,56 @@ class _BackupPageState extends State<BackupPage>
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
body: Column(
children: [
_buildHeader(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
return BlocProvider(
create: (_) => sl<BackupBloc>()
..add(LoadBackups())
..add(LoadBackupConfig()),
child: BlocConsumer<BackupBloc, BackupState>(
listener: (context, state) {
if (state is BackupsLoaded) {
_cachedBackups = state.backups;
} else if (state is BackupConfigLoaded) {
_cachedConfig = state.config;
}
if (state is BackupSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: const Color(0xFF00B894),
behavior: SnackBarBehavior.floating,
),
);
} else if (state is BackupError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: const Color(0xFFD63031),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
return Scaffold(
backgroundColor: ColorTokens.background,
body: Column(
children: [
_buildBackupsTab(),
_buildScheduleTab(),
_buildRestoreTab(),
_buildHeader(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildBackupsTab(state),
_buildScheduleTab(),
_buildRestoreTab(),
],
),
),
],
),
),
],
);
},
),
);
}
@@ -138,15 +183,27 @@ class _BackupPageState extends State<BackupPage>
Row(
children: [
Expanded(
child: _buildStatCard('Dernière sauvegarde', '2h', Icons.schedule),
child: _buildStatCard(
'Dernière sauvegarde',
_lastBackupDisplay(),
Icons.schedule,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard('Taille totale', '2.3 GB', Icons.storage),
child: _buildStatCard(
'Taille totale',
_totalSizeDisplay(),
Icons.storage,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard('Statut', 'OK', Icons.check_circle),
child: _buildStatCard(
'Statut',
_statusDisplay(),
Icons.check_circle,
),
),
],
),
@@ -155,6 +212,58 @@ class _BackupPageState extends State<BackupPage>
);
}
String _lastBackupDisplay() {
if (_cachedConfig?.lastBackup != null) {
final d = _cachedConfig!.lastBackup!;
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays} j';
return '${d.day}/${d.month}/${d.year}';
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final d = sorted.first.createdAt;
if (d != null) {
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays} j';
}
}
return '';
}
String _totalSizeDisplay() {
if (_cachedConfig?.totalSizeFormatted != null && _cachedConfig!.totalSizeFormatted!.isNotEmpty) {
return _cachedConfig!.totalSizeFormatted!;
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
int total = 0;
for (final b in _cachedBackups!) {
total += b.sizeBytes ?? 0;
}
if (total >= 1024 * 1024 * 1024) return '${(total / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
if (total >= 1024 * 1024) return '${(total / (1024 * 1024)).toStringAsFixed(1)} MB';
if (total >= 1024) return '${(total / 1024).toStringAsFixed(0)} KB';
return '$total B';
}
return '0 B';
}
String _statusDisplay() {
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final s = sorted.first.status;
if (s == 'COMPLETED') return 'OK';
if (s == 'FAILED') return 'Erreur';
if (s == 'IN_PROGRESS') return 'En cours';
}
return 'OK';
}
/// Carte de statistique
Widget _buildStatCard(String label, String value, IconData icon) {
return Container(
@@ -220,13 +329,15 @@ class _BackupPageState extends State<BackupPage>
}
/// Onglet sauvegardes
Widget _buildBackupsTab() {
Widget _buildBackupsTab(BackupState state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const SizedBox(height: 16),
_buildBackupsList(),
state is BackupLoading
? const Center(child: CircularProgressIndicator())
: _buildBackupsList(state is BackupsLoaded ? state.backups : (_cachedBackups ?? [])),
const SizedBox(height: 80),
],
),
@@ -234,12 +345,14 @@ class _BackupPageState extends State<BackupPage>
}
/// Liste des sauvegardes
Widget _buildBackupsList() {
final backups = [
{'name': 'Sauvegarde automatique', 'date': '15/12/2024 02:00', 'size': '2.3 GB', 'type': 'Auto'},
{'name': 'Sauvegarde manuelle', 'date': '14/12/2024 14:30', 'size': '2.1 GB', 'type': 'Manuel'},
{'name': 'Sauvegarde automatique', 'date': '14/12/2024 02:00', 'size': '2.2 GB', 'type': 'Auto'},
];
Widget _buildBackupsList(List<dynamic> backupsData) {
final backups = backupsData.map((backup) => {
'id': backup.id?.toString() ?? '',
'name': backup.name ?? 'Sans nom',
'date': backup.createdAt?.toString() ?? '',
'size': backup.sizeFormatted ?? '0 B',
'type': backup.type ?? 'Manual',
}).toList();
return Container(
padding: const EdgeInsets.all(16),
@@ -279,7 +392,7 @@ class _BackupPageState extends State<BackupPage>
}
/// Élément de sauvegarde
Widget _buildBackupItem(Map<String, String> backup) {
Widget _buildBackupItem(Map<String, dynamic> backup) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
@@ -554,11 +667,103 @@ class _BackupPageState extends State<BackupPage>
}
// Méthodes d'action
void _createBackupNow() => _showSuccessSnackBar('Sauvegarde créée avec succès');
void _handleBackupAction(Map<String, String> backup, String action) => _showSuccessSnackBar('Action "$action" exécutée');
void _restoreFromFile() => _showSuccessSnackBar('Sélection de fichier de restauration');
void _selectiveRestore() => _showSuccessSnackBar('Mode de restauration sélective');
void _createRestorePoint() => _showSuccessSnackBar('Point de restauration créé');
void _createBackupNow() {
context.read<BackupBloc>().add(CreateBackup('Sauvegarde manuelle', description: 'Créée depuis l\'application mobile'));
}
void _handleBackupAction(Map<String, dynamic> backup, String action) {
final backupId = backup['id'];
if (backupId == null) return;
if (action == 'restore') {
context.read<BackupBloc>().add(RestoreBackup(backupId));
} else if (action == 'delete') {
context.read<BackupBloc>().add(DeleteBackup(backupId));
} else if (action == 'download') {
_downloadBackup(backupId);
} else {
_showSuccessSnackBar('Action "$action" exécutée');
}
}
Future<void> _downloadBackup(String backupId) async {
try {
final repo = sl<BackupRepository>();
final b = await repo.getById(backupId);
if (b.filePath != null && b.filePath!.isNotEmpty) {
try {
await Share.share(
b.filePath!,
subject: 'Sauvegarde ${b.name ?? backupId}',
);
_showSuccessSnackBar('Partage du lien de téléchargement');
} catch (e, st) {
AppLogger.error('BackupPage: partage échoué', error: e, stackTrace: st);
_showSuccessSnackBar('Téléchargement: configurez l\'URL de téléchargement côté backend');
}
} else {
_showSuccessSnackBar('Téléchargement: l\'API ne fournit pas encore de lien (filePath).');
}
} catch (e, st) {
AppLogger.error('BackupPage: téléchargement échoué', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de récupérer la sauvegarde.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _restoreFromFile() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.single.path;
if (path != null && path.isNotEmpty) {
_showSuccessSnackBar('Fichier sélectionné. Restauration depuis fichier à brancher côté API.');
} else {
_showSuccessSnackBar('Restauration depuis fichier à brancher côté API.');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration depuis fichier', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection de fichier impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _selectiveRestore() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: true,
);
if (result == null || result.files.isEmpty) {
_showSuccessSnackBar('Restauration sélective: sélectionnez un ou plusieurs fichiers.');
return;
}
final paths = result.files.map((f) => f.path).whereType<String>().toList();
if (paths.isNotEmpty) {
_showSuccessSnackBar('Restauration sélective: ${paths.length} fichier(s) (API à brancher).');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration sélective', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
void _createRestorePoint() {
context.read<BackupBloc>().add(CreateBackup('Point de restauration', description: 'Point de restauration'));
}
void _showSuccessSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -0,0 +1,192 @@
# Feature Communication/Messaging
**Status**: ✅ **Implémenté** (MVP Fonctionnel)
**Date**: 2026-03-13
**Priorité**: P0 (Bloquant Production)
## 📋 Vue d'ensemble
Module de communication permettant la messagerie entre membres et les broadcasts organisation selon les permissions RBAC.
## 🎯 Fonctionnalités Implémentées
### ✅ MVP (V1.0)
1. **Liste des Conversations**
- Affichage conversations triées par date
- Badge compteur messages non lus
- Indicateurs visuels (pinned, muted)
- Pull-to-refresh
- Navigation vers détail conversation
2. **Permissions Respectées**
- `COMM_SEND_ALL` - OrgAdmin, SuperAdmin
- `COMM_SEND_MEMBERS` - Moderator
- `COMM_BROADCAST` - OrgAdmin
- Menu "Messages" visible selon rôle (OrgAdmin, SuperAdmin, Moderator)
3. **Architecture Clean + BLoC**
- Domain : Entities (Message, Conversation, MessageTemplate)
- Data : Models avec JSON serialization, Repository, Datasource
- Presentation : BLoC (Events, States), Pages, Widgets
4. **Intégration App**
- Routes : `/messages`, `/communication`
- Navigation : Menu "Plus" avec vérification permissions
- DI : Injectable + GetIt
## 🏗️ Architecture
```
communication/
├── domain/
│ ├── entities/
│ │ ├── message.dart (Message, MessageType, MessageStatus, MessagePriority)
│ │ ├── conversation.dart (Conversation, ConversationType)
│ │ └── message_template.dart (MessageTemplate, TemplateCategory)
│ ├── repositories/
│ │ └── messaging_repository.dart (interface)
│ └── usecases/
│ ├── get_conversations.dart
│ ├── get_messages.dart
│ ├── send_message.dart
│ └── send_broadcast.dart
├── data/
│ ├── models/
│ │ ├── message_model.dart (.g.dart généré)
│ │ └── conversation_model.dart (.g.dart généré)
│ ├── datasources/
│ │ └── messaging_remote_datasource.dart (API REST)
│ └── repositories/
│ └── messaging_repository_impl.dart
└── presentation/
├── bloc/
│ ├── messaging_event.dart
│ ├── messaging_state.dart
│ └── messaging_bloc.dart
├── pages/
│ └── conversations_page.dart
└── widgets/
└── conversation_tile.dart
```
## 📡 API Endpoints Utilisés
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/messaging/conversations` | GET | Liste conversations |
| `/api/messaging/conversations/:id` | GET | Détail conversation |
| `/api/messaging/conversations` | POST | Créer conversation |
| `/api/messaging/conversations/:id/messages` | GET | Messages d'une conversation |
| `/api/messaging/conversations/:id/messages` | POST | Envoyer message |
| `/api/messaging/broadcast` | POST | Envoyer broadcast |
| `/api/messaging/messages/:id/read` | PUT | Marquer message lu |
| `/api/messaging/unread/count` | GET | Compteur non lus |
**⚠️ Note**: Backend endpoints à implémenter côté serveur Quarkus
## 🔄 États BLoC
- `MessagingInitial` - État initial
- `MessagingLoading` - Chargement en cours
- `ConversationsLoaded` - Conversations chargées avec compteur non lus
- `MessagesLoaded` - Messages d'une conversation chargés
- `MessageSent` - Message envoyé avec succès
- `BroadcastSent` - Broadcast envoyé avec succès
- `MessagingError` - Erreur avec message utilisateur
## 🚀 Prochaines Étapes (V2.0+)
### P1 - Fonctionnalités Avancées
- [ ] Page détail conversation (chat thread)
- [ ] Envoi pièces jointes (images, documents)
- [ ] Édition/suppression messages
- [ ] Recherche dans conversations
- [ ] Filtres conversations (non lus, pinned, archivées)
- [ ] Templates messages personnalisables (CRUD)
- [ ] Messages ciblés par rôles (COMM_TARGETED)
- [ ] Modération messages (MODERATION_CONTENT)
- [ ] Statistiques communication (dashboard analytics)
### P2 - Optimisations
- [ ] WebSocket temps réel pour nouveaux messages
- [ ] Cache local conversations récentes
- [ ] Pagination messages (infinite scroll)
- [ ] Compression images avant envoi
- [ ] Mode offline avec synchronisation
- [ ] Notifications push (FCM)
- [ ] Read receipts (accusés de lecture)
- [ ] Typing indicators (en train d'écrire)
## 🧪 Tests
### À Implémenter
- [ ] Unit tests BLoC (bloc_test)
- [ ] Unit tests UseCases (mockito)
- [ ] Unit tests Repository (mockito)
- [ ] Widget tests ConversationsPage
- [ ] Integration tests flux complet
## 📝 Notes Techniques
### JSON Serialization
Le champ `lastMessage` dans `Conversation` utilise une sérialisation custom car `Message` est un type nested :
```dart
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
final Message? lastMessage;
```
### Gestion d'Erreurs
Toutes les méthodes repository retournent `Either<Failure, T>` pour une gestion fonctionnelle des erreurs :
- `NetworkFailure` - Pas de connexion Internet
- `UnauthorizedFailure` - Token expiré (401)
- `ForbiddenFailure` - Permission insuffisante (403)
- `NotFoundFailure` - Ressource non trouvée (404)
- `ServerFailure` - Erreur serveur (5xx)
- `ValidationFailure` - Données invalides
- `UnexpectedFailure` - Erreur inattendue
- `NotImplementedFailure` - Fonctionnalité en développement
### Dépendances Externes
Module `RegisterModule` enregistre :
- `http.Client` pour requêtes HTTP
- `FlutterSecureStorage` pour tokens
- `Connectivity` pour état réseau
## 📚 Documentation Connexe
- [Permission Matrix](../../features/authentication/data/models/permission_matrix.dart)
- [User Roles](../../features/authentication/data/models/user_role.dart)
- [API Design](../../specs/000-unionflow-baseline/spec.md)
- [Audit Métier](../../AUDIT_METIER_COMPLET.md)
## ✅ Critères d'Acceptation
- [x] Architecture Clean + BLoC respectée
- [x] Permissions RBAC vérifiées (OrgAdmin, SuperAdmin, Moderator)
- [x] Routes intégrées (/messages, /communication)
- [x] Menu navigation avec vérification rôles
- [x] Page liste conversations fonctionnelle
- [x] Gestion erreurs complète (Failures)
- [x] DI configuré (Injectable + GetIt)
- [x] JSON serialization (.g.dart générés)
- [x] Code compilable sans erreurs
- [ ] Backend endpoints implémentés (Quarkus)
- [ ] Tests unitaires BLoC
- [ ] Tests intégration E2E
---
**Développé avec**: Flutter 3.5.3+, Dart 3.x, BLoC 8.1.6, Clean Architecture
**Gap comblé**: Communication/Messaging (P0 Bloquant Production)

View File

@@ -0,0 +1,230 @@
/// Datasource distant pour la communication (API)
library messaging_remote_datasource;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../models/message_model.dart';
import '../models/conversation_model.dart';
import '../../domain/entities/message.dart';
@lazySingleton
class MessagingRemoteDatasource {
final http.Client client;
final FlutterSecureStorage secureStorage;
MessagingRemoteDatasource({
required this.client,
required this.secureStorage,
});
/// Headers HTTP avec authentification
Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'access_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
// === CONVERSATIONS ===
Future<List<ConversationModel>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
'includeArchived': includeArchived.toString(),
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => ConversationModel.fromJson(json))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des conversations');
}
}
Future<ConversationModel> getConversationById(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Conversation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
}
}
Future<ConversationModel> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/conversations');
final body = json.encode({
'name': name,
'participantIds': participantIds,
if (organizationId != null) 'organizationId': organizationId,
if (description != null) 'description': description,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la création de la conversation');
}
}
// === MESSAGES ===
Future<List<MessageModel>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId/messages')
.replace(queryParameters: {
if (limit != null) 'limit': limit.toString(),
if (beforeMessageId != null) 'beforeMessageId': beforeMessageId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => MessageModel.fromJson(json)).toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des messages');
}
}
Future<MessageModel> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId/messages');
final body = json.encode({
'content': content,
if (attachments != null) 'attachments': attachments,
'priority': priority.name,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de l\'envoi du message');
}
}
Future<MessageModel> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/broadcast');
final body = json.encode({
'organizationId': organizationId,
'subject': subject,
'content': content,
'priority': priority.name,
if (attachments != null) 'attachments': attachments,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour envoyer un broadcast');
} else {
throw ServerException('Erreur lors de l\'envoi du broadcast');
}
}
Future<void> markMessageAsRead(String messageId) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/messages/$messageId/read');
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors du marquage du message comme lu');
}
}
}
Future<int> getUnreadCount({String? organizationId}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/unread/count')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['count'] as int;
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du compte non lu');
}
}
}

View File

@@ -0,0 +1,70 @@
/// Model de données Conversation avec sérialisation JSON
library conversation_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart';
part 'conversation_model.g.dart';
@JsonSerializable(explicitToJson: true)
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
required super.id,
required super.name,
super.description,
required super.type,
required super.participantIds,
super.organizationId,
this.lastMessage,
super.unreadCount,
super.isMuted,
super.isPinned,
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
static Message? _messageFromJson(Map<String, dynamic>? json) =>
json == null ? null : MessageModel.fromJson(json);
static Map<String, dynamic>? _messageToJson(Message? message) =>
message == null ? null : MessageModel.fromEntity(message).toJson();
factory ConversationModel.fromJson(Map<String, dynamic> json) =>
_$ConversationModelFromJson(json);
Map<String, dynamic> toJson() => _$ConversationModelToJson(this);
factory ConversationModel.fromEntity(Conversation conversation) {
return ConversationModel(
id: conversation.id,
name: conversation.name,
description: conversation.description,
type: conversation.type,
participantIds: conversation.participantIds,
organizationId: conversation.organizationId,
lastMessage: conversation.lastMessage,
unreadCount: conversation.unreadCount,
isMuted: conversation.isMuted,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
avatarUrl: conversation.avatarUrl,
metadata: conversation.metadata,
);
}
Conversation toEntity() => this;
}

View File

@@ -0,0 +1,83 @@
/// Model de données Message avec sérialisation JSON
library message_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/message.dart';
part 'message_model.g.dart';
@JsonSerializable(explicitToJson: true)
class MessageModel extends Message {
const MessageModel({
required super.id,
required super.conversationId,
required super.senderId,
required super.senderName,
super.senderAvatar,
required super.content,
required super.type,
required super.status,
super.priority,
required super.recipientIds,
super.recipientRoles,
super.organizationId,
required super.createdAt,
super.readAt,
super.metadata,
super.attachments,
super.isEdited,
super.editedAt,
super.isDeleted,
});
factory MessageModel.fromJson(Map<String, dynamic> json) =>
_$MessageModelFromJson(json);
Map<String, dynamic> toJson() => _$MessageModelToJson(this);
factory MessageModel.fromEntity(Message message) {
return MessageModel(
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
senderName: message.senderName,
senderAvatar: message.senderAvatar,
content: message.content,
type: message.type,
status: message.status,
priority: message.priority,
recipientIds: message.recipientIds,
recipientRoles: message.recipientRoles,
organizationId: message.organizationId,
createdAt: message.createdAt,
readAt: message.readAt,
metadata: message.metadata,
attachments: message.attachments,
isEdited: message.isEdited,
editedAt: message.editedAt,
isDeleted: message.isDeleted,
);
}
Message toEntity() => Message(
id: id,
conversationId: conversationId,
senderId: senderId,
senderName: senderName,
senderAvatar: senderAvatar,
content: content,
type: type,
status: status,
priority: priority,
recipientIds: recipientIds,
recipientRoles: recipientRoles,
organizationId: organizationId,
createdAt: createdAt,
readAt: readAt,
metadata: metadata,
attachments: attachments,
isEdited: isEdited,
editedAt: editedAt,
isDeleted: isDeleted,
);
}

View File

@@ -0,0 +1,329 @@
/// Implémentation du repository de messagerie
library messaging_repository_impl;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import '../../domain/entities/message_template.dart';
import '../../domain/repositories/messaging_repository.dart';
import '../datasources/messaging_remote_datasource.dart';
@LazySingleton(as: MessagingRepository)
class MessagingRepositoryImpl implements MessagingRepository {
final MessagingRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
MessagingRepositoryImpl({
required this.remoteDatasource,
required this.networkInfo,
});
@override
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversations = await remoteDatasource.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
return Right(conversations);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Conversation>> getConversationById(
String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversation =
await remoteDatasource.getConversationById(conversationId);
return Right(conversation);
} on NotFoundException {
return Left(NotFoundFailure('Conversation non trouvée'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversation = await remoteDatasource.createConversation(
name: name,
participantIds: participantIds,
organizationId: organizationId,
description: description,
);
return Right(conversation);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final messages = await remoteDatasource.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
return Right(messages);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
return Right(message);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
);
return Right(message);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, void>> markMessageAsRead(String messageId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
await remoteDatasource.markMessageAsRead(messageId);
return const Right(null);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, int>> getUnreadCount({String? organizationId}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final count =
await remoteDatasource.getUnreadCount(organizationId: organizationId);
return Right(count);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
// === MÉTHODES NON IMPLÉMENTÉES (Stubs pour compilation) ===
// À implémenter selon besoins backend
@override
Future<Either<Failure, void>> archiveConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> markConversationAsRead(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> toggleMuteConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> togglePinConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteMessage(String messageId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteTemplate(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
}

View File

@@ -0,0 +1,127 @@
/// Entité métier Conversation
///
/// Représente une conversation (fil de messages) dans UnionFlow
library conversation;
import 'package:equatable/equatable.dart';
import 'message.dart';
/// Type de conversation
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
/// Conversation de groupe
group,
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
final String id;
final String name;
final String? description;
final ConversationType type;
final List<String> participantIds;
final String? organizationId;
final Message? lastMessage;
final int unreadCount;
final bool isMuted;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
const Conversation({
required this.id,
required this.name,
this.description,
required this.type,
required this.participantIds,
this.organizationId,
this.lastMessage,
this.unreadCount = 0,
this.isMuted = false,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
});
/// Vérifie si la conversation a des messages non lus
bool get hasUnread => unreadCount > 0;
/// Vérifie si c'est une conversation individuelle
bool get isIndividual => type == ConversationType.individual;
/// Vérifie si c'est un broadcast
bool get isBroadcast => type == ConversationType.broadcast;
/// Nombre de participants
int get participantCount => participantIds.length;
/// Copie avec modifications
Conversation copyWith({
String? id,
String? name,
String? description,
ConversationType? type,
List<String>? participantIds,
String? organizationId,
Message? lastMessage,
int? unreadCount,
bool? isMuted,
bool? isPinned,
bool? isArchived,
DateTime? createdAt,
DateTime? updatedAt,
String? avatarUrl,
Map<String, dynamic>? metadata,
}) {
return Conversation(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
type: type ?? this.type,
participantIds: participantIds ?? this.participantIds,
organizationId: organizationId ?? this.organizationId,
lastMessage: lastMessage ?? this.lastMessage,
unreadCount: unreadCount ?? this.unreadCount,
isMuted: isMuted ?? this.isMuted,
isPinned: isPinned ?? this.isPinned,
isArchived: isArchived ?? this.isArchived,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
avatarUrl: avatarUrl ?? this.avatarUrl,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
name,
description,
type,
participantIds,
organizationId,
lastMessage,
unreadCount,
isMuted,
isPinned,
isArchived,
createdAt,
updatedAt,
avatarUrl,
metadata,
];
}

View File

@@ -0,0 +1,173 @@
/// Entité métier Message
///
/// Représente un message dans le système de communication UnionFlow
library message;
import 'package:equatable/equatable.dart';
/// Type de message
enum MessageType {
/// Message individuel (membre à membre)
individual,
/// Broadcast organisation (OrgAdmin → tous)
broadcast,
/// Message ciblé par rôle (Moderator → groupe)
targeted,
/// Notification système
system,
}
/// Statut de lecture du message
enum MessageStatus {
/// Envoyé mais non lu
sent,
/// Livré (reçu par le serveur)
delivered,
/// Lu par le destinataire
read,
/// Échec d'envoi
failed,
}
/// Priorité du message
enum MessagePriority {
/// Priorité normale
normal,
/// Priorité élevée (important)
high,
/// Priorité urgente (critique)
urgent,
}
/// Entité Message
class Message extends Equatable {
final String id;
final String conversationId;
final String senderId;
final String senderName;
final String? senderAvatar;
final String content;
final MessageType type;
final MessageStatus status;
final MessagePriority priority;
final List<String> recipientIds;
final List<String>? recipientRoles;
final String? organizationId;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
const Message({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderName,
this.senderAvatar,
required this.content,
required this.type,
required this.status,
this.priority = MessagePriority.normal,
required this.recipientIds,
this.recipientRoles,
this.organizationId,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
});
/// Vérifie si le message a été lu
bool get isRead => status == MessageStatus.read;
/// Vérifie si le message est urgent
bool get isUrgent => priority == MessagePriority.urgent;
/// Vérifie si le message est un broadcast
bool get isBroadcast => type == MessageType.broadcast;
/// Vérifie si le message a des pièces jointes
bool get hasAttachments => attachments != null && attachments!.isNotEmpty;
/// Copie avec modifications
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<String>? recipientIds,
List<String>? recipientRoles,
String? organizationId,
DateTime? createdAt,
DateTime? readAt,
Map<String, dynamic>? metadata,
List<String>? attachments,
bool? isEdited,
DateTime? editedAt,
bool? isDeleted,
}) {
return Message(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
senderAvatar: senderAvatar ?? this.senderAvatar,
content: content ?? this.content,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
recipientIds: recipientIds ?? this.recipientIds,
recipientRoles: recipientRoles ?? this.recipientRoles,
organizationId: organizationId ?? this.organizationId,
createdAt: createdAt ?? this.createdAt,
readAt: readAt ?? this.readAt,
metadata: metadata ?? this.metadata,
attachments: attachments ?? this.attachments,
isEdited: isEdited ?? this.isEdited,
editedAt: editedAt ?? this.editedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
];
}

View File

@@ -0,0 +1,154 @@
/// Entité métier Template de Message
///
/// Templates réutilisables pour notifications et broadcasts
library message_template;
import 'package:equatable/equatable.dart';
/// Catégorie de template
enum TemplateCategory {
/// Événements
events,
/// Finances
finances,
/// Adhésions
membership,
/// Solidarité
solidarity,
/// Système
system,
/// Personnalisé
custom,
}
/// Variables dynamiques dans les templates
class TemplateVariable {
final String name;
final String description;
final String placeholder;
final bool required;
const TemplateVariable({
required this.name,
required this.description,
required this.placeholder,
this.required = true,
});
}
/// Entité Template de Message
class MessageTemplate extends Equatable {
final String id;
final String name;
final String description;
final TemplateCategory category;
final String subject;
final String body;
final List<TemplateVariable> variables;
final String? organizationId;
final String createdBy;
final DateTime createdAt;
final DateTime? updatedAt;
final bool isActive;
final bool isSystem;
final int usageCount;
final Map<String, dynamic>? metadata;
const MessageTemplate({
required this.id,
required this.name,
required this.description,
required this.category,
required this.subject,
required this.body,
this.variables = const [],
this.organizationId,
required this.createdBy,
required this.createdAt,
this.updatedAt,
this.isActive = true,
this.isSystem = false,
this.usageCount = 0,
this.metadata,
});
/// Vérifie si le template est éditable (pas système)
bool get isEditable => !isSystem;
/// Génère un message à partir du template avec des valeurs
String generateMessage(Map<String, String> values) {
String result = body;
for (final variable in variables) {
final value = values[variable.name];
if (value != null) {
result = result.replaceAll('{{${variable.name}}}', value);
} else if (variable.required) {
throw ArgumentError('Variable requise manquante: ${variable.name}');
}
}
return result;
}
/// Copie avec modifications
MessageTemplate copyWith({
String? id,
String? name,
String? description,
TemplateCategory? category,
String? subject,
String? body,
List<TemplateVariable>? variables,
String? organizationId,
String? createdBy,
DateTime? createdAt,
DateTime? updatedAt,
bool? isActive,
bool? isSystem,
int? usageCount,
Map<String, dynamic>? metadata,
}) {
return MessageTemplate(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
category: category ?? this.category,
subject: subject ?? this.subject,
body: body ?? this.body,
variables: variables ?? this.variables,
organizationId: organizationId ?? this.organizationId,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isActive: isActive ?? this.isActive,
isSystem: isSystem ?? this.isSystem,
usageCount: usageCount ?? this.usageCount,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
name,
description,
category,
subject,
body,
variables,
organizationId,
createdBy,
createdAt,
updatedAt,
isActive,
isSystem,
usageCount,
metadata,
];
}

View File

@@ -0,0 +1,145 @@
/// Repository interface pour la communication
///
/// Contrat de données pour les messages, conversations et templates
library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart';
import '../entities/message_template.dart';
/// Interface du repository de messagerie
abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
});
/// Récupère une conversation par son ID
Future<Either<Failure, Conversation>> getConversationById(String conversationId);
/// Crée une nouvelle conversation
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
});
/// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId);
/// Marque une conversation comme lue
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
/// Mute/démute une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId);
/// Pin/unpin une conversation
Future<Either<Failure, void>> togglePinConversation(String conversationId);
// === MESSAGES ===
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
});
/// Envoie un message individuel
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
});
/// Envoie un broadcast à toute l'organisation
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Envoie un message ciblé par rôles
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Marque un message comme lu
Future<Either<Failure, void>> markMessageAsRead(String messageId);
/// Édite un message
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
});
/// Supprime un message
Future<Either<Failure, void>> deleteMessage(String messageId);
// === TEMPLATES ===
/// Récupère tous les templates disponibles
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
});
/// Récupère un template par son ID
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId);
/// Crée un nouveau template
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
});
/// Met à jour un template
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
});
/// Supprime un template
Future<Either<Failure, void>> deleteTemplate(String templateId);
/// Envoie un message à partir d'un template
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
});
// === STATISTIQUES ===
/// Récupère le nombre de messages non lus
Future<Either<Failure, int>> getUnreadCount({String? organizationId});
/// Récupère les statistiques de communication
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
});
}

View File

@@ -0,0 +1,25 @@
/// Use case: Récupérer les conversations
library get_conversations;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/conversation.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class GetConversations {
final MessagingRepository repository;
GetConversations(this.repository);
Future<Either<Failure, List<Conversation>>> call({
String? organizationId,
bool includeArchived = false,
}) async {
return await repository.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
}
}

View File

@@ -0,0 +1,31 @@
/// Use case: Récupérer les messages d'une conversation
library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class GetMessages {
final MessagingRepository repository;
GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
if (conversationId.isEmpty) {
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
}
}

View File

@@ -0,0 +1,44 @@
/// Use case: Envoyer un broadcast organisation
library send_broadcast;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class SendBroadcast {
final MessagingRepository repository;
SendBroadcast(this.repository);
Future<Either<Failure, Message>> call({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
// Validation
if (subject.trim().isEmpty) {
return Left(ValidationFailure('Le sujet ne peut pas être vide'));
}
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
if (organizationId.isEmpty) {
return Left(ValidationFailure('ID organisation requis'));
}
return await repository.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
);
}
}

View File

@@ -0,0 +1,34 @@
/// Use case: Envoyer un message
library send_message;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class SendMessage {
final MessagingRepository repository;
SendMessage(this.repository);
Future<Either<Failure, Message>> call({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
// Validation
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
return await repository.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
}
}

View File

@@ -0,0 +1,105 @@
/// BLoC de gestion de la messagerie
library messaging_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_conversations.dart';
import '../../domain/usecases/get_messages.dart';
import '../../domain/usecases/send_message.dart';
import '../../domain/usecases/send_broadcast.dart';
import 'messaging_event.dart';
import 'messaging_state.dart';
@injectable
class MessagingBloc extends Bloc<MessagingEvent, MessagingState> {
final GetConversations getConversations;
final GetMessages getMessages;
final SendMessage sendMessage;
final SendBroadcast sendBroadcast;
MessagingBloc({
required this.getConversations,
required this.getMessages,
required this.sendMessage,
required this.sendBroadcast,
}) : super(MessagingInitial()) {
on<LoadConversations>(_onLoadConversations);
on<LoadMessages>(_onLoadMessages);
on<SendMessageEvent>(_onSendMessage);
on<SendBroadcastEvent>(_onSendBroadcast);
}
Future<void> _onLoadConversations(
LoadConversations event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
final result = await getConversations(
organizationId: event.organizationId,
includeArchived: event.includeArchived,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(conversations) => emit(ConversationsLoaded(conversations: conversations)),
);
}
Future<void> _onLoadMessages(
LoadMessages event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
final result = await getMessages(
conversationId: event.conversationId,
limit: event.limit,
beforeMessageId: event.beforeMessageId,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(messages) => emit(MessagesLoaded(
conversationId: event.conversationId,
messages: messages,
hasMore: messages.length == (event.limit ?? 50),
)),
);
}
Future<void> _onSendMessage(
SendMessageEvent event,
Emitter<MessagingState> emit,
) async {
final result = await sendMessage(
conversationId: event.conversationId,
content: event.content,
attachments: event.attachments,
priority: event.priority,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(MessageSent(message)),
);
}
Future<void> _onSendBroadcast(
SendBroadcastEvent event,
Emitter<MessagingState> emit,
) async {
final result = await sendBroadcast(
organizationId: event.organizationId,
subject: event.subject,
content: event.content,
priority: event.priority,
attachments: event.attachments,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(BroadcastSent(message)),
);
}
}

View File

@@ -0,0 +1,118 @@
/// Événements du BLoC Messaging
library messaging_event;
import 'package:equatable/equatable.dart';
import '../../domain/entities/message.dart';
abstract class MessagingEvent extends Equatable {
const MessagingEvent();
@override
List<Object?> get props => [];
}
/// Charger les conversations
class LoadConversations extends MessagingEvent {
final String? organizationId;
final bool includeArchived;
const LoadConversations({
this.organizationId,
this.includeArchived = false,
});
@override
List<Object?> get props => [organizationId, includeArchived];
}
/// Charger les messages d'une conversation
class LoadMessages extends MessagingEvent {
final String conversationId;
final int? limit;
final String? beforeMessageId;
const LoadMessages({
required this.conversationId,
this.limit,
this.beforeMessageId,
});
@override
List<Object?> get props => [conversationId, limit, beforeMessageId];
}
/// Envoyer un message
class SendMessageEvent extends MessagingEvent {
final String conversationId;
final String content;
final List<String>? attachments;
final MessagePriority priority;
const SendMessageEvent({
required this.conversationId,
required this.content,
this.attachments,
this.priority = MessagePriority.normal,
});
@override
List<Object?> get props => [conversationId, content, attachments, priority];
}
/// Envoyer un broadcast
class SendBroadcastEvent extends MessagingEvent {
final String organizationId;
final String subject;
final String content;
final MessagePriority priority;
final List<String>? attachments;
const SendBroadcastEvent({
required this.organizationId,
required this.subject,
required this.content,
this.priority = MessagePriority.normal,
this.attachments,
});
@override
List<Object?> get props => [organizationId, subject, content, priority, attachments];
}
/// Marquer un message comme lu
class MarkMessageAsReadEvent extends MessagingEvent {
final String messageId;
const MarkMessageAsReadEvent(this.messageId);
@override
List<Object?> get props => [messageId];
}
/// Charger le nombre de messages non lus
class LoadUnreadCount extends MessagingEvent {
final String? organizationId;
const LoadUnreadCount({this.organizationId});
@override
List<Object?> get props => [organizationId];
}
/// Créer une nouvelle conversation
class CreateConversationEvent extends MessagingEvent {
final String name;
final List<String> participantIds;
final String? organizationId;
final String? description;
const CreateConversationEvent({
required this.name,
required this.participantIds,
this.organizationId,
this.description,
});
@override
List<Object?> get props => [name, participantIds, organizationId, description];
}

View File

@@ -0,0 +1,99 @@
/// États du BLoC Messaging
library messaging_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
abstract class MessagingState extends Equatable {
const MessagingState();
@override
List<Object?> get props => [];
}
/// État initial
class MessagingInitial extends MessagingState {}
/// Chargement en cours
class MessagingLoading extends MessagingState {}
/// Conversations chargées
class ConversationsLoaded extends MessagingState {
final List<Conversation> conversations;
final int unreadCount;
const ConversationsLoaded({
required this.conversations,
this.unreadCount = 0,
});
@override
List<Object?> get props => [conversations, unreadCount];
}
/// Messages d'une conversation chargés
class MessagesLoaded extends MessagingState {
final String conversationId;
final List<Message> messages;
final bool hasMore;
const MessagesLoaded({
required this.conversationId,
required this.messages,
this.hasMore = false,
});
@override
List<Object?> get props => [conversationId, messages, hasMore];
}
/// Message envoyé avec succès
class MessageSent extends MessagingState {
final Message message;
const MessageSent(this.message);
@override
List<Object?> get props => [message];
}
/// Broadcast envoyé avec succès
class BroadcastSent extends MessagingState {
final Message message;
const BroadcastSent(this.message);
@override
List<Object?> get props => [message];
}
/// Conversation créée
class ConversationCreated extends MessagingState {
final Conversation conversation;
const ConversationCreated(this.conversation);
@override
List<Object?> get props => [conversation];
}
/// Compteur de non lus chargé
class UnreadCountLoaded extends MessagingState {
final int count;
const UnreadCountLoaded(this.count);
@override
List<Object?> get props => [count];
}
/// Erreur
class MessagingError extends MessagingState {
final String message;
const MessagingError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,150 @@
/// Page liste des conversations
library conversations_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../bloc/messaging_bloc.dart';
import '../bloc/messaging_event.dart';
import '../bloc/messaging_state.dart';
import '../widgets/conversation_tile.dart';
class ConversationsPage extends StatelessWidget {
final String? organizationId;
const ConversationsPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<MessagingBloc>()
..add(LoadConversations(organizationId: organizationId)),
child: Scaffold(
backgroundColor: ColorTokens.background,
appBar: const UFAppBar(
title: 'MESSAGES',
automaticallyImplyLeading: true,
),
body: BlocBuilder<MessagingBloc, MessagingState>(
builder: (context, state) {
if (state is MessagingLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is MessagingError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Erreur',
style: AppTypography.headerSmall,
),
const SizedBox(height: SpacingTokens.sm),
Text(
state.message,
style: AppTypography.bodyTextSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId),
);
},
),
],
),
);
}
if (state is ConversationsLoaded) {
final conversations = state.conversations;
if (conversations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 64,
color: AppColors.textSecondaryLight,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Aucune conversation',
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Commencez une nouvelle conversation',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId),
);
},
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: conversations.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm),
itemBuilder: (context, index) {
final conversation = conversations[index];
return ConversationTile(
conversation: conversation,
onTap: () {
// Navigation vers la page de chat
// TODO: Implémenter navigation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ouvrir conversation: ${conversation.name}'),
),
);
},
);
},
),
);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.primaryGreen,
onPressed: () {
// TODO: Ouvrir dialogue nouvelle conversation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nouvelle conversation (à implémenter)')),
);
},
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
}

View File

@@ -0,0 +1,166 @@
/// Widget tuile de conversation
library conversation_tile;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/conversation.dart';
class ConversationTile extends StatelessWidget {
final Conversation conversation;
final VoidCallback onTap;
const ConversationTile({
super.key,
required this.conversation,
required this.onTap,
});
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return DateFormat('HH:mm').format(date);
} else if (difference.inDays == 1) {
return 'Hier';
} else if (difference.inDays < 7) {
return DateFormat('EEEE', 'fr_FR').format(date);
} else {
return DateFormat('dd/MM/yy').format(date);
}
}
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
child: Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(
color: conversation.hasUnread
? AppColors.primaryGreen.withOpacity(0.3)
: ColorTokens.outline,
),
),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 24,
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
backgroundImage: conversation.avatarUrl != null
? NetworkImage(conversation.avatarUrl!)
: null,
child: conversation.avatarUrl == null
? Text(
conversation.name.isNotEmpty
? conversation.name[0].toUpperCase()
: '?',
style: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
),
)
: null,
),
const SizedBox(width: SpacingTokens.md),
// Contenu
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
conversation.name,
style: AppTypography.actionText.copyWith(
fontWeight: conversation.hasUnread
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (conversation.lastMessage != null)
Text(
_formatDate(conversation.lastMessage!.createdAt),
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
if (conversation.lastMessage != null) ...[
const SizedBox(height: 4),
Text(
conversation.lastMessage!.content,
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
fontWeight: conversation.hasUnread
? FontWeight.w600
: FontWeight.normal,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Badge non lus
if (conversation.hasUnread) ...[
const SizedBox(width: SpacingTokens.sm),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.primaryGreen,
borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular),
),
child: Text(
'${conversation.unreadCount}',
style: AppTypography.badgeText.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
// Icônes statut
if (conversation.isPinned || conversation.isMuted) ...[
const SizedBox(width: SpacingTokens.sm),
Column(
children: [
if (conversation.isPinned)
Icon(
Icons.push_pin,
size: 16,
color: AppColors.textSecondaryLight,
),
if (conversation.isMuted)
Icon(
Icons.volume_off,
size: 16,
color: AppColors.textSecondaryLight,
),
],
),
],
],
),
),
);
}
}

View File

@@ -2,17 +2,43 @@
library contributions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/contribution_model.dart';
import '../data/repositories/contribution_repository.dart';
import '../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../domain/usecases/get_contributions.dart';
import '../domain/usecases/get_contribution_by_id.dart';
import '../domain/usecases/create_contribution.dart' as uc;
import '../domain/usecases/update_contribution.dart' as uc;
import '../domain/usecases/delete_contribution.dart' as uc;
import '../domain/usecases/pay_contribution.dart';
import '../domain/usecases/get_contribution_stats.dart';
import '../domain/repositories/contribution_repository.dart';
import 'contributions_event.dart';
import 'contributions_state.dart';
/// BLoC pour gérer l'état des contributions via l'API backend
/// BLoC pour gérer l'état des contributions via les use cases (Clean Architecture)
@injectable
class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
final ContributionRepository _repository;
final GetContributions _getContributions;
final GetContributionById _getContributionById;
final uc.CreateContribution _createContribution;
final uc.UpdateContribution _updateContribution;
final uc.DeleteContribution _deleteContribution;
final PayContribution _payContribution;
final GetContributionStats _getContributionStats;
final IContributionRepository _repository; // Pour méthodes non-couvertes par use cases
ContributionsBloc(this._repository) : super(const ContributionsInitial()) {
ContributionsBloc(
this._getContributions,
this._getContributionById,
this._createContribution,
this._updateContribution,
this._deleteContribution,
this._payContribution,
this._getContributionStats,
this._repository,
) : super(const ContributionsInitial()) {
on<LoadContributions>(_onLoadContributions);
on<LoadContributionById>(_onLoadContributionById);
on<CreateContribution>(_onCreateContribution);
@@ -41,10 +67,8 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
emit(const ContributionsLoading(message: 'Chargement des contributions...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
);
// Use case: Get contributions
final result = await _getContributions(page: event.page, size: event.size);
emit(ContributionsLoaded(
contributions: result.contributions,
@@ -70,7 +94,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement de la contribution...'));
final contribution = await _repository.getCotisationById(event.id);
final contribution = await _getContributionById(event.id);
emit(ContributionDetailLoaded(contribution: contribution));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -84,7 +108,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Création de la contribution...'));
final created = await _repository.createCotisation(event.contribution);
final created = await _createContribution(event.contribution);
emit(ContributionCreated(contribution: created));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -98,7 +122,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Mise à jour de la contribution...'));
final updated = await _repository.updateCotisation(event.id, event.contribution);
final updated = await _updateContribution(event.id, event.contribution);
emit(ContributionUpdated(contribution: updated));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -112,7 +136,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Suppression de la contribution...'));
await _repository.deleteCotisation(event.id);
await _deleteContribution(event.id);
emit(ContributionDeleted(id: event.id));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -181,19 +205,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions payées...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'PAYEE',
);
final result = await _repository.getMesCotisations();
final payees = result.contributions.where((c) => c.statut == ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: payees,
total: payees.length,
page: 0,
size: payees.length,
totalPages: payees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -207,19 +226,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions non payées...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'NON_PAYEE',
);
final result = await _repository.getMesCotisations();
final nonPayees = result.contributions.where((c) => c.statut != ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: nonPayees,
total: nonPayees.length,
page: 0,
size: nonPayees.length,
totalPages: nonPayees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -233,19 +247,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions en retard...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'EN_RETARD',
);
final result = await _repository.getMesCotisations();
final enRetard = result.contributions.where((c) => c.statut == ContributionStatus.enRetard || c.estEnRetard).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: enRetard,
total: enRetard.length,
page: 0,
size: enRetard.length,
totalPages: enRetard.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -260,8 +269,8 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
try {
emit(const ContributionsLoading(message: 'Enregistrement du paiement...'));
final updated = await _repository.enregistrerPaiement(
event.contributionId,
final updated = await _payContribution(
cotisationId: event.contributionId,
montant: event.montant,
datePaiement: event.datePaiement,
methodePaiement: event.methodePaiement.name,
@@ -280,16 +289,54 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
LoadContributionsStats event,
Emitter<ContributionsState> emit,
) async {
List<ContributionModel>? preservedList = state is ContributionsLoaded ? (state as ContributionsLoaded).contributions : null;
try {
emit(const ContributionsLoading(message: 'Chargement des statistiques...'));
// Charger synthèse + liste pour que la page « Mes statistiques » ait toujours donut et prochaines échéances
final mesSynthese = await _getContributionStats();
final listResult = preservedList == null ? await _getContributions() : null;
final contributions = preservedList ?? listResult?.contributions;
if (mesSynthese != null && mesSynthese.isNotEmpty) {
final normalized = _normalizeSyntheseForStats(mesSynthese);
emit(ContributionsStatsLoaded(stats: normalized, contributions: contributions));
return;
}
final stats = await _repository.getStatistiques();
emit(ContributionsStatsLoaded(stats: stats.map((k, v) => MapEntry(k, (v is num) ? v.toDouble() : 0.0))));
emit(ContributionsStatsLoaded(
stats: stats.map((k, v) => MapEntry(k, v is num ? v.toDouble() : (v is int ? v.toDouble() : 0.0))),
contributions: contributions,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
/// Normalise la réponse synthese (mes) pour l'affichage stats (clés numériques + isMesSynthese).
Map<String, dynamic> _normalizeSyntheseForStats(Map<String, dynamic> s) {
final montantDu = _toDouble(s['montantDu']);
final totalPayeAnnee = _toDouble(s['totalPayeAnnee']);
final totalAnnee = montantDu + totalPayeAnnee;
final taux = totalAnnee > 0 ? (totalPayeAnnee / totalAnnee * 100) : 0.0;
return {
'isMesSynthese': true,
'cotisationsEnAttente': (s['cotisationsEnAttente'] is int) ? s['cotisationsEnAttente'] as int : ((s['cotisationsEnAttente'] as num?)?.toInt() ?? 0),
'montantDu': montantDu,
'totalPayeAnnee': totalPayeAnnee,
'totalMontant': totalAnnee,
'tauxPaiement': taux,
'prochaineEcheance': s['prochaineEcheance']?.toString(),
'anneeEnCours': s['anneeEnCours'] is int ? s['anneeEnCours'] as int : ((s['anneeEnCours'] as num?)?.toInt() ?? DateTime.now().year),
};
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
Future<void> _onGenerateAnnualContributions(
GenerateAnnualContributions event,
Emitter<ContributionsState> emit,

View File

@@ -102,14 +102,16 @@ class PaymentRecorded extends ContributionsState {
List<Object?> get props => [contribution];
}
/// État statistiques chargées
/// État statistiques chargées (liste optionnelle conservée pour ne pas perdre l'onglet Toutes au retour)
class ContributionsStatsLoaded extends ContributionsState {
final Map<String, dynamic> stats;
/// Liste des contributions conservée depuis l'état précédent (ex: au retour de la page Stats).
final List<ContributionModel>? contributions;
const ContributionsStatsLoaded({required this.stats});
const ContributionsStatsLoaded({required this.stats, this.contributions});
@override
List<Object?> get props => [stats];
List<Object?> get props => [stats, contributions];
}
/// État contributions générées

View File

@@ -12,6 +12,8 @@ enum ContributionStatus {
payee,
@JsonValue('NON_PAYEE')
nonPayee,
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('EN_RETARD')
enRetard,
@JsonValue('PARTIELLE')
@@ -56,6 +58,23 @@ enum PaymentMethod {
autre,
}
/// Extension pour obtenir le code API d'une méthode de paiement (ex: pour icônes assets).
extension PaymentMethodCode on PaymentMethod {
String get code {
switch (this) {
case PaymentMethod.especes: return 'ESPECES';
case PaymentMethod.cheque: return 'CHEQUE';
case PaymentMethod.virement: return 'VIREMENT';
case PaymentMethod.carteBancaire: return 'CARTE_BANCAIRE';
case PaymentMethod.waveMoney: return 'WAVE_MONEY';
case PaymentMethod.orangeMoney: return 'ORANGE_MONEY';
case PaymentMethod.freeMoney: return 'FREE_MONEY';
case PaymentMethod.mobileMoney: return 'MOBILE_MONEY';
case PaymentMethod.autre: return 'AUTRE';
}
}
}
/// Modèle complet d'une contribution
@JsonSerializable(explicitToJson: true)
class ContributionModel extends Equatable {

View File

@@ -93,6 +93,7 @@ const _$ContributionTypeEnumMap = {
const _$ContributionStatusEnumMap = {
ContributionStatus.payee: 'PAYEE',
ContributionStatus.nonPayee: 'NON_PAYEE',
ContributionStatus.enAttente: 'EN_ATTENTE',
ContributionStatus.enRetard: 'EN_RETARD',
ContributionStatus.partielle: 'PARTIELLE',
ContributionStatus.annulee: 'ANNULEE',

View File

@@ -1,17 +1,109 @@
/// Repository pour la gestion des cotisations via l'API backend
library contribution_repository;
/// Implémentation du repository des cotisations via l'API backend
library contribution_repository_impl;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../domain/repositories/contribution_repository.dart';
import '../models/contribution_model.dart';
/// Repository des cotisations - appels API réels vers /api/cotisations
class ContributionRepository {
final Dio _dio;
/// Implémentation du repository des cotisations - appels API réels vers /api/cotisations
@LazySingleton(as: IContributionRepository)
class ContributionRepositoryImpl implements IContributionRepository {
final ApiClient _apiClient;
static const String _baseUrl = '/api/cotisations';
ContributionRepository(this._dio);
ContributionRepositoryImpl(this._apiClient);
/// Récupère la liste des cotisations avec pagination
/// Toutes les cotisations du membre connecté (GET /api/cotisations/mes-cotisations).
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50}) async {
final response = await _apiClient.get(
'$_baseUrl/mes-cotisations',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data as List<dynamic> : <dynamic>[];
final contributions = list.map((e) => _summaryToModel(e as Map<String, dynamic>)).toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: page,
size: size,
totalPages: list.isEmpty ? 0 : 1,
);
}
/// Récupère les cotisations en attente du membre connecté (endpoint dédié).
Future<ContributionPageResult> getMesCotisationsEnAttente() async {
final path = '$_baseUrl/mes-cotisations/en-attente';
final response = await _apiClient.get(path);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data : (data is Map ? (data['data'] ?? data['content'] ?? []) as List<dynamic>? ?? [] : []);
final contributions = list
.map((e) => _summaryToModel(e as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: 0,
size: contributions.length,
totalPages: contributions.isEmpty ? 0 : 1,
);
}
static ContributionModel _summaryToModel(Map<String, dynamic> json) {
final id = json['id']?.toString();
final statutStr = json['statut'] as String? ?? 'EN_ATTENTE';
final statut = _mapStatut(statutStr);
final montantDu = (json['montantDu'] as num?)?.toDouble() ?? 0.0;
final montantPaye = (json['montantPaye'] as num?)?.toDouble();
final dateEcheanceStr = json['dateEcheance'] as String?;
final dateEcheance = dateEcheanceStr != null
? DateTime.tryParse(dateEcheanceStr) ?? DateTime.now()
: DateTime.now();
final annee = (json['annee'] as num?)?.toInt() ?? dateEcheance.year;
return ContributionModel(
id: id,
membreId: '', // membre implicite (endpoint "mes cotisations")
membreNom: (json['nomMembre'] ?? json['nomCompletMembre']) as String?,
type: ContributionType.annuelle,
statut: statut,
montant: montantDu,
montantPaye: montantPaye,
devise: 'XOF',
dateEcheance: dateEcheance,
annee: annee,
);
}
static ContributionStatus _mapStatut(String code) {
switch (code.toUpperCase()) {
case 'PAYEE':
return ContributionStatus.payee;
case 'EN_RETARD':
return ContributionStatus.enRetard;
case 'PARTIELLE':
return ContributionStatus.partielle;
case 'ANNULEE':
return ContributionStatus.annulee;
case 'EN_ATTENTE':
case 'NON_PAYEE':
default:
return ContributionStatus.nonPayee;
}
}
/// Récupère la liste des cotisations avec pagination (toutes cotisations, nécessite droits admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
@@ -29,7 +121,7 @@ class ContributionRepository {
if (type != null) queryParams['type'] = type;
if (annee != null) queryParams['annee'] = annee;
final response = await _dio.get(
final response = await _apiClient.get(
_baseUrl,
queryParameters: queryParams,
);
@@ -66,25 +158,96 @@ class ContributionRepository {
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id) async {
final response = await _dio.get('$_baseUrl/$id');
final response = await _apiClient.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Cotisation non trouvée');
}
/// Crée une nouvelle cotisation
/// Crée une nouvelle cotisation (payload conforme au backend CreateCotisationRequest)
Future<ContributionModel> createCotisation(ContributionModel contribution) async {
final response = await _dio.post(_baseUrl, data: contribution.toJson());
final body = _toCreateCotisationRequest(contribution);
final response = await _apiClient.post(_baseUrl, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
final data = Map<String, dynamic>.from(response.data as Map<String, dynamic>);
_normalizeCotisationResponse(data);
return ContributionModel.fromJson(data);
}
throw Exception('Erreur lors de la création: ${response.statusCode}');
final message = response.data is Map
? (response.data as Map)['error'] ?? response.data.toString()
: response.data?.toString() ?? 'Erreur ${response.statusCode}';
throw Exception('Erreur lors de la création: $message');
}
/// Construit le body attendu par POST /api/cotisations (CreateCotisationRequest)
static Map<String, dynamic> _toCreateCotisationRequest(ContributionModel c) {
if (c.organisationId == null || c.organisationId!.trim().isEmpty) {
throw Exception('L\'organisation du membre est requise pour créer une cotisation.');
}
final typeStr = _contributionTypeToBackend(c.type);
final dateStr = _formatLocalDate(c.dateEcheance);
final desc = c.description?.trim();
final libelle = desc != null && desc.isNotEmpty
? (desc.length > 100 ? desc.substring(0, 100) : desc)
: 'Cotisation $typeStr ${c.annee}';
final description = desc != null && desc.isNotEmpty
? (desc.length > 500 ? desc.substring(0, 500) : desc)
: null;
return {
'membreId': c.membreId,
'organisationId': c.organisationId!.trim(),
'typeCotisation': typeStr,
'libelle': libelle,
if (description != null) 'description': description,
'montantDu': c.montant,
'codeDevise': c.devise.length == 3 ? c.devise : 'XOF',
'dateEcheance': dateStr,
'periode': '${_monthName(c.dateEcheance.month)} ${c.dateEcheance.year}',
'annee': c.annee,
'mois': c.mois ?? c.dateEcheance.month,
'recurrente': false,
if (c.notes != null && c.notes!.isNotEmpty) 'observations': c.notes,
};
}
static String _contributionTypeToBackend(ContributionType t) {
switch (t) {
case ContributionType.mensuelle:
return 'MENSUELLE';
case ContributionType.trimestrielle:
return 'TRIMESTRIELLE';
case ContributionType.semestrielle:
return 'SEMESTRIELLE';
case ContributionType.annuelle:
return 'ANNUELLE';
case ContributionType.exceptionnelle:
return 'EXCEPTIONNELLE';
}
}
static String _formatLocalDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
static String _monthName(int month) {
const names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
return month >= 1 && month <= 12 ? names[month - 1] : 'Mois $month';
}
/// Adapte les clés de la réponse backend (CotisationResponse) vers le modèle mobile
static void _normalizeCotisationResponse(Map<String, dynamic> data) {
if (data.containsKey('nomMembre') && !data.containsKey('membreNom')) data['membreNom'] = data['nomMembre'];
if (data.containsKey('nomOrganisation') && !data.containsKey('organisationNom')) data['organisationNom'] = data['nomOrganisation'];
if (data.containsKey('codeDevise') && !data.containsKey('devise')) data['devise'] = data['codeDevise'];
if (data.containsKey('montantDu') && !data.containsKey('montant')) data['montant'] = data['montantDu'];
if (data['id'] != null && data['id'] is! String) data['id'] = data['id'].toString();
if (data['membreId'] != null && data['membreId'] is! String) data['membreId'] = data['membreId'].toString();
if (data['organisationId'] != null && data['organisationId'] is! String) data['organisationId'] = data['organisationId'].toString();
}
/// Met à jour une cotisation
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution) async {
final response = await _dio.put('$_baseUrl/$id', data: contribution.toJson());
final response = await _apiClient.put('$_baseUrl/$id', data: contribution.toJson());
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -93,12 +256,46 @@ class ContributionRepository {
/// Supprime une cotisation
Future<void> deleteCotisation(String id) async {
final response = await _dio.delete('$_baseUrl/$id');
final response = await _apiClient.delete('$_baseUrl/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Erreur lors de la suppression: ${response.statusCode}');
}
}
/// Initie un paiement en ligne (Wave Checkout API).
/// Retourne l'URL à ouvrir (wave_launch_url) pour que le membre confirme dans l'app Wave.
/// Spec: https://docs.wave.com/checkout
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
}) async {
final response = await _apiClient.post(
'/api/paiements/initier-paiement-en-ligne',
data: {
'cotisationId': cotisationId,
'methodePaiement': methodePaiement,
'numeroTelephone': numeroTelephone.replaceAll(RegExp(r'\D'), ''),
},
);
if (response.statusCode != 201 && response.statusCode != 200) {
final msg = response.data is Map
? (response.data['message'] ?? response.data['error'] ?? response.statusCode)
: response.statusCode;
throw Exception('Impossible d\'initier le paiement: $msg');
}
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
return WavePaiementInitResult(
redirectUrl: data['redirectUrl'] as String? ?? data['waveLaunchUrl'] as String? ?? '',
waveLaunchUrl: data['waveLaunchUrl'] as String? ?? data['redirectUrl'] as String? ?? '',
waveCheckoutSessionId: data['waveCheckoutSessionId'] as String?,
clientReference: data['clientReference'] as String?,
message: data['message'] as String? ?? 'Ouvrez Wave pour confirmer le paiement.',
);
}
/// Enregistre un paiement
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
@@ -108,7 +305,7 @@ class ContributionRepository {
String? numeroPaiement,
String? referencePaiement,
}) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_baseUrl/$cotisationId/paiement',
data: {
'montant': montant,
@@ -124,9 +321,27 @@ class ContributionRepository {
throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}');
}
/// Récupère les statistiques des cotisations
/// Synthèse personnelle du membre connecté (GET /api/cotisations/mes-cotisations/synthese)
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await _apiClient.get('$_baseUrl/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
data['isMesSynthese'] = true;
return data;
}
return null;
} catch (e, st) {
AppLogger.error('ContributionRepository: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
/// Récupère les statistiques des cotisations (globales ou mes selon usage)
Future<Map<String, dynamic>> getStatistiques() async {
final response = await _dio.get('$_baseUrl/statistiques');
final response = await _apiClient.get('$_baseUrl/statistiques');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
@@ -135,7 +350,7 @@ class ContributionRepository {
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId) async {
final response = await _dio.post('$_baseUrl/$cotisationId/rappel');
final response = await _apiClient.post('$_baseUrl/$cotisationId/rappel');
if (response.statusCode != 200) {
throw Exception('Erreur lors de l\'envoi du rappel');
}
@@ -143,7 +358,7 @@ class ContributionRepository {
/// Génère les cotisations annuelles
Future<int> genererCotisationsAnnuelles(int annee) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_baseUrl/generer',
data: {'annee': annee},
);
@@ -154,6 +369,23 @@ class ContributionRepository {
}
}
/// Résultat de l'initiation d'un paiement Wave (redirection vers l'app Wave).
class WavePaiementInitResult {
final String redirectUrl;
final String waveLaunchUrl;
final String? waveCheckoutSessionId;
final String? clientReference;
final String message;
const WavePaiementInitResult({
required this.redirectUrl,
required this.waveLaunchUrl,
this.waveCheckoutSessionId,
this.clientReference,
required this.message,
});
}
/// Résultat paginé de cotisations
class ContributionPageResult {
final List<ContributionModel> contributions;

View File

@@ -1,21 +0,0 @@
/// Configuration de l'injection de dépendances pour le module Cotisations
library cotisations_di;
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import '../bloc/contributions_bloc.dart';
import '../data/repositories/contribution_repository.dart';
/// Enregistrer les dépendances du module Cotisations
void registerCotisationsDependencies(GetIt getIt) {
// Repository
getIt.registerLazySingleton<ContributionRepository>(
() => ContributionRepository(getIt<Dio>()),
);
// BLoC
getIt.registerFactory<ContributionsBloc>(
() => ContributionsBloc(getIt<ContributionRepository>()),
);
}

View File

@@ -0,0 +1,66 @@
/// Interface du repository des contributions (Clean Architecture)
library contribution_repository_interface;
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult, WavePaiementInitResult;
/// Interface définissant le contrat du repository des contributions
/// Implémentée par ContributionRepositoryImpl dans la couche data
abstract class IContributionRepository {
/// Récupère toutes les cotisations du membre connecté
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50});
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id);
/// Crée une nouvelle cotisation
Future<ContributionModel> createCotisation(ContributionModel contribution);
/// Met à jour une cotisation existante
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution);
/// Supprime une cotisation
Future<void> deleteCotisation(String id);
/// Enregistre un paiement pour une cotisation
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
});
/// Initie un paiement en ligne (Wave)
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
});
/// Récupère la synthèse des cotisations du membre
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Récupère les statistiques globales
Future<Map<String, dynamic>> getStatistiques();
/// Récupère les cotisations en attente
Future<ContributionPageResult> getMesCotisationsEnAttente();
/// Récupère les cotisations avec filtres (admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
String? membreId,
String? statut,
String? type,
int? annee,
});
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId);
/// Génère les cotisations annuelles pour tous les membres
Future<int> genererCotisationsAnnuelles(int annee);
}

View File

@@ -0,0 +1,24 @@
/// Use case: Créer une nouvelle contribution
library create_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour créer une nouvelle cotisation
@injectable
class CreateContribution {
final IContributionRepository _repository;
CreateContribution(this._repository);
/// Exécute le use case
///
/// [contribution] - Modèle de la cotisation à créer
///
/// Retourne la contribution créée avec son ID généré
/// Lève une exception en cas d'erreur de validation ou de création
Future<ContributionModel> call(ContributionModel contribution) async {
return _repository.createCotisation(contribution);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Supprimer une contribution
library delete_contribution;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour supprimer une cotisation
@injectable
class DeleteContribution {
final IContributionRepository _repository;
DeleteContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à supprimer
///
/// Supprime la contribution de manière définitive
/// Lève une exception si la contribution n'existe pas ou ne peut être supprimée
Future<void> call(String id) async {
return _repository.deleteCotisation(id);
}
}

View File

@@ -0,0 +1,24 @@
/// Use case: Récupérer une contribution par son ID
library get_contribution_by_id;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer le détail d'une contribution
@injectable
class GetContributionById {
final IContributionRepository _repository;
GetContributionById(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation
///
/// Retourne le détail complet de la contribution
/// Lève une exception si la contribution n'existe pas
Future<ContributionModel> call(String id) async {
return _repository.getCotisationById(id);
}
}

View File

@@ -0,0 +1,33 @@
/// Use case: Récupérer l'historique des contributions d'un membre
library get_contribution_history;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer l'historique des paiements de cotisations
@injectable
class GetContributionHistory {
final IContributionRepository _repository;
GetContributionHistory(this._repository);
/// Exécute le use case
///
/// [page] - Numéro de page (pagination)
/// [size] - Taille de la page
/// [annee] - Filtrer par année (optionnel)
/// [statut] - Filtrer par statut (optionnel)
///
/// Retourne l'historique paginé des cotisations du membre
/// Inclut toutes les cotisations (payées, en attente, en retard)
Future<ContributionPageResult> call({
int page = 0,
int size = 50,
int? annee,
ContributionStatus? statut,
}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,27 @@
/// Use case: Récupérer les statistiques personnelles des contributions
library get_contribution_stats;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer les statistiques de cotisations du membre
@injectable
class GetContributionStats {
final IContributionRepository _repository;
GetContributionStats(this._repository);
/// Exécute le use case
///
/// Retourne un Map contenant les statistiques personnelles:
/// - montantDu: Montant total dû pour l'année en cours
/// - totalPayeAnnee: Montant total payé pour l'année
/// - cotisationsEnAttente: Nombre de cotisations en attente
/// - prochaineEcheance: Date de la prochaine échéance
/// - tauxPaiement: Taux de paiement en pourcentage
///
/// Retourne null si aucune donnée n'est disponible
Future<Map<String, dynamic>?> call() async {
return _repository.getMesCotisationsSynthese();
}
}

View File

@@ -0,0 +1,22 @@
/// Use case: Récupérer toutes les contributions du membre connecté
library get_contributions;
import 'package:injectable/injectable.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer la liste des contributions du membre connecté
@injectable
class GetContributions {
final IContributionRepository _repository;
GetContributions(this._repository);
/// Exécute le use case
///
/// Retourne la liste paginée des cotisations du membre connecté
/// via l'endpoint GET /api/cotisations/mes-cotisations
Future<ContributionPageResult> call({int page = 0, int size = 50}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,43 @@
/// Use case: Enregistrer un paiement pour une contribution
library pay_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour enregistrer un paiement de cotisation
@injectable
class PayContribution {
final IContributionRepository _repository;
PayContribution(this._repository);
/// Exécute le use case
///
/// [cotisationId] - UUID de la cotisation à payer
/// [montant] - Montant du paiement
/// [datePaiement] - Date du paiement
/// [methodePaiement] - Méthode de paiement (WAVE, ESPECES, VIREMENT, etc.)
/// [numeroPaiement] - Numéro de transaction (optionnel)
/// [referencePaiement] - Référence du paiement (optionnel)
///
/// Retourne la contribution mise à jour avec le paiement enregistré
/// Lève une exception en cas d'erreur de validation ou d'enregistrement
Future<ContributionModel> call({
required String cotisationId,
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
}) async {
return _repository.enregistrerPaiement(
cotisationId,
montant: montant,
datePaiement: datePaiement,
methodePaiement: methodePaiement,
numeroPaiement: numeroPaiement,
referencePaiement: referencePaiement,
);
}
}

View File

@@ -0,0 +1,25 @@
/// Use case: Mettre à jour une contribution existante
library update_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour modifier une cotisation
@injectable
class UpdateContribution {
final IContributionRepository _repository;
UpdateContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à modifier
/// [contribution] - Données mises à jour
///
/// Retourne la contribution modifiée
/// Lève une exception si la contribution n'existe pas ou erreur de validation
Future<ContributionModel> call(String id, ContributionModel contribution) async {
return _repository.updateCotisation(id, contribution);
}
}

View File

@@ -1,20 +1,20 @@
/// Page de gestion des contributions
library contributions_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
import '../../../../shared/widgets/error_widget.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_state.dart';
import 'package:unionflow_mobile_apps/features/contributions/data/models/contribution_model.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/payment_dialog.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
import '../widgets/payment_dialog.dart';
import '../../../members/bloc/membres_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart';
/// Page principale des contributions
/// Page de gestion des contributions - Version Design System
class ContributionsPage extends StatefulWidget {
const ContributionsPage({super.key});
@@ -25,13 +25,12 @@ class ContributionsPage extends StatefulWidget {
class _ContributionsPageState extends State<ContributionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadContributions();
}
@override
@@ -60,60 +59,39 @@ class _ContributionsPageState extends State<ContributionsPage>
@override
Widget build(BuildContext context) {
return BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
// Gestion des erreurs avec SnackBar
if (state is ContributionsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: _loadContributions,
),
),
);
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Cotisations'),
bottom: TabBar(
controller: _tabController,
onTap: (_) => _loadContributions(),
tabs: const [
Tab(text: 'Toutes', icon: Icon(Icons.list)),
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
Tab(text: 'Non payées', icon: Icon(Icons.pending)),
Tab(text: 'En retard', icon: Icon(Icons.warning)),
],
),
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Cotisations',
actions: [
IconButton(
icon: const Icon(Icons.bar_chart),
icon: const Icon(Icons.bar_chart, size: 20),
onPressed: () => _showStats(),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.add),
icon: const Icon(Icons.add_circle_outline, size: 20),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle contribution',
),
],
),
body: TabBarView(
bottom: TabBar(
controller: _tabController,
children: [
_buildContributionsList(),
_buildContributionsList(),
_buildContributionsList(),
_buildContributionsList(),
onTap: (_) => _loadContributions(),
labelColor: ColorTokens.onPrimary,
unselectedLabelColor: ColorTokens.onPrimary.withOpacity(0.7),
indicatorColor: ColorTokens.onPrimary,
labelStyle: AppTypography.badgeText.copyWith(fontWeight: FontWeight.bold),
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Payées'),
Tab(text: 'Dues'),
Tab(text: 'Retard'),
],
),
),
body: TabBarView(
controller: _tabController,
children: List.generate(4, (_) => _buildContributionsList()),
),
);
}
@@ -134,379 +112,274 @@ class _ContributionsPageState extends State<ContributionsPage>
}
if (state is ContributionsLoaded) {
if (state.contributions.isEmpty) {
return const Center(
child: EmptyDataWidget(
message: 'Aucune contribution trouvée',
icon: Icons.payment,
),
);
}
return RefreshIndicator(
onRefresh: () async => _loadContributions(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.contributions.length,
itemBuilder: (context, index) {
final contribution = state.contributions[index];
return _buildContributionCard(contribution);
},
),
);
return _buildListOrEmpty(state.contributions);
}
return const Center(child: Text('Chargez les cotisations'));
// Au retour de "Mes Statistiques", la liste peut être conservée dans ContributionsStatsLoaded
if (state is ContributionsStatsLoaded) {
if (state.contributions != null) {
return _buildListOrEmpty(state.contributions!);
}
// Stats ouverts sans liste préalable : charger les contributions une fois
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.read<ContributionsBloc>().add(const LoadContributions());
}
});
return const Center(child: Text('Initialisation...'));
}
return const Center(child: Text('Initialisation...'));
},
);
}
Widget _buildContributionCard(ContributionModel contribution) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _showContributionDetails(contribution),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contribution.membreNomComplet,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
contribution.libellePeriode,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
_buildStatutChip(contribution.statut),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Montant',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_currencyFormat.format(contribution.montant),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
if (contribution.montantPaye != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Payé',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_currencyFormat.format(contribution.montantPaye),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Échéance',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
style: TextStyle(
fontSize: 14,
color: contribution.estEnRetard ? Colors.red : null,
),
),
],
),
],
),
if (contribution.statut == ContributionStatus.partielle)
Padding(
padding: const EdgeInsets.only(top: 12),
child: LinearProgressIndicator(
value: contribution.pourcentagePaye / 100,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
],
Widget _buildListOrEmpty(List<ContributionModel> contributions) {
if (contributions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.payment_outlined, size: 48, color: ColorTokens.onSurfaceVariant.withOpacity(0.5)),
const SizedBox(height: SpacingTokens.md),
Text('Aucune contribution', style: AppTypography.bodyTextSmall),
],
),
);
}
return Column(
children: [
_buildMiniStats(contributions),
Expanded(
child: RefreshIndicator(
onRefresh: () async => _loadContributions(),
child: ListView.builder(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: contributions.length,
itemBuilder: (context, index) => _buildContributionCard(contributions[index]),
),
),
),
],
);
}
Widget _buildMiniStats(List<ContributionModel> contributions) {
final totalDue = contributions.fold(0.0, (sum, c) => sum + c.montant);
final totalPaid = contributions.fold(0.0, (sum, c) => sum + (c.montantPaye ?? 0.0));
return Container(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm),
color: ColorTokens.surfaceVariant.withOpacity(0.3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetric('DU', _currencyFormat.format(totalDue), ColorTokens.secondary),
_buildMetric('PAYÉ', _currencyFormat.format(totalPaid), ColorTokens.success),
_buildMetric('RESTANT', _currencyFormat.format(totalDue - totalPaid), ColorTokens.error),
],
),
);
}
Widget _buildStatutChip(ContributionStatus statut) {
Color color;
String label;
IconData icon;
Widget _buildMetric(String label, String value, Color color) {
return Column(
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.headerSmall.copyWith(color: color, fontWeight: FontWeight.bold)),
],
);
}
Widget _buildContributionCard(ContributionModel contribution) {
return UFCard(
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
onTap: () => _showContributionDetails(contribution),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
],
),
),
_buildStatutBadge(contribution.statut, contribution.estEnRetard),
],
),
const SizedBox(height: SpacingTokens.md),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildAmountValue('Montant', contribution.montant),
if (contribution.montantPaye != null && contribution.montantPaye! > 0)
_buildAmountValue('Payé', contribution.montantPaye!, color: ColorTokens.success),
_buildAmountValue('Échéance', contribution.dateEcheance, isDate: true),
],
),
if (contribution.statut == ContributionStatus.partielle) ...[
const SizedBox(height: SpacingTokens.sm),
ClipRRect(
borderRadius: BorderRadius.circular(RadiusTokens.sm),
child: LinearProgressIndicator(
value: contribution.pourcentagePaye / 100,
backgroundColor: ColorTokens.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.primary),
minHeight: 4,
),
),
],
],
),
);
}
Widget _buildAmountValue(String label, dynamic value, {Color? color, bool isDate = false}) {
String displayValue = isDate
? DateFormat('dd/MM/yy').format(value as DateTime)
: _currencyFormat.format(value as double);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(displayValue, style: AppTypography.bodyTextSmall.copyWith(
color: color ?? ColorTokens.onSurface,
fontWeight: FontWeight.w600,
)),
],
);
}
Widget _buildStatutBadge(ContributionStatus statut, bool enRetard) {
if (enRetard && statut != ContributionStatus.payee) {
return const InfoBadge(text: 'RETARD', backgroundColor: Color(0xFFFFEBEB), textColor: ColorTokens.error);
}
switch (statut) {
case ContributionStatus.payee:
color = Colors.green;
label = 'Payée';
icon = Icons.check_circle;
break;
return const InfoBadge(text: 'PAYÉE', backgroundColor: Color(0xFFE3F9E5), textColor: ColorTokens.success);
case ContributionStatus.nonPayee:
color = Colors.orange;
label = 'Non payée';
icon = Icons.pending;
break;
case ContributionStatus.enRetard:
color = Colors.red;
label = 'En retard';
icon = Icons.warning;
break;
case ContributionStatus.enAttente:
return const InfoBadge(text: 'DUE', backgroundColor: Color(0xFFFFF4E5), textColor: ColorTokens.warning);
case ContributionStatus.partielle:
color = Colors.blue;
label = 'Partielle';
icon = Icons.hourglass_bottom;
break;
return const InfoBadge(text: 'PARTIELLE', backgroundColor: Color(0xFFE5F1FF), textColor: ColorTokens.info);
case ContributionStatus.annulee:
color = Colors.grey;
label = 'Annulée';
icon = Icons.cancel;
break;
return InfoBadge.neutral('ANNULÉE');
default:
return InfoBadge.neutral(statut.name.toUpperCase());
}
return Chip(
avatar: Icon(icon, size: 16, color: Colors.white),
label: Text(label),
backgroundColor: color,
labelStyle: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
}
void _showContributionDetails(ContributionModel contribution) {
showDialog(
showModalBottomSheet(
context: context,
builder: (context) => AlertDialog(
title: Text(contribution.membreNomComplet),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Période', contribution.libellePeriode),
_buildDetailRow('Montant', _currencyFormat.format(contribution.montant)),
if (contribution.montantPaye != null)
_buildDetailRow('Payé', _currencyFormat.format(contribution.montantPaye)),
_buildDetailRow('Restant', _currencyFormat.format(contribution.montantRestant)),
_buildDetailRow(
'Échéance',
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
),
if (contribution.datePaiement != null)
_buildDetailRow(
'Date paiement',
DateFormat('dd/MM/yyyy').format(contribution.datePaiement!),
),
if (contribution.methodePaiement != null)
_buildDetailRow('Méthode', _getMethodePaiementLabel(contribution.methodePaiement!)),
backgroundColor: ColorTokens.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(RadiusTokens.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.all(SpacingTokens.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
const Divider(height: SpacingTokens.xl),
_buildDetailRow('Montant Total', _currencyFormat.format(contribution.montant)),
_buildDetailRow('Montant Payé', _currencyFormat.format(contribution.montantPaye ?? 0.0)),
_buildDetailRow('Reste à payer', _currencyFormat.format(contribution.montantRestant), isCritical: contribution.montantRestant > 0),
_buildDetailRow('Date d\'échéance', DateFormat('dd MMMM yyyy').format(contribution.dateEcheance)),
if (contribution.description != null) ...[
const SizedBox(height: SpacingTokens.md),
Text(contribution.description!, style: AppTypography.bodyTextSmall),
],
),
),
actions: [
if (contribution.statut != ContributionStatus.payee)
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_showPaymentDialog(contribution);
},
icon: const Icon(Icons.payment),
label: const Text('Enregistrer paiement'),
const SizedBox(height: SpacingTokens.xl),
Row(
children: [
if (contribution.statut != ContributionStatus.payee)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: UFPrimaryButton(
label: 'Enregistrer Paiement',
onPressed: () {
Navigator.pop(context);
_showPaymentDialog(contribution);
},
),
),
),
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: ColorTokens.outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.md)),
),
child: Text('Fermer', style: AppTypography.actionText.copyWith(color: ColorTokens.onSurface)),
),
),
],
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
],
),
),
);
}
Widget _buildDetailRow(String label, String value) {
Widget _buildDetailRow(String label, String value, {bool isCritical = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.bold,
color: isCritical ? ColorTokens.error : ColorTokens.onSurface,
)),
],
),
);
}
String _getMethodePaiementLabel(PaymentMethod methode) {
switch (methode) {
case PaymentMethod.especes:
return 'Espèces';
case PaymentMethod.cheque:
return 'Chèque';
case PaymentMethod.virement:
return 'Virement';
case PaymentMethod.carteBancaire:
return 'Carte bancaire';
case PaymentMethod.waveMoney:
return 'Wave Money';
case PaymentMethod.orangeMoney:
return 'Orange Money';
case PaymentMethod.freeMoney:
return 'Free Money';
case PaymentMethod.mobileMoney:
return 'Mobile Money';
case PaymentMethod.autre:
return 'Autre';
}
}
void _showPaymentDialog(ContributionModel contribution) {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => BlocProvider.value(
value: context.read<ContributionsBloc>(),
value: contributionsBloc,
child: PaymentDialog(cotisation: contribution),
),
);
}
void _showCreateDialog() {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<ContributionsBloc>()),
BlocProvider.value(value: context.read<MembresBloc>()),
],
builder: (context) => BlocProvider.value(
value: contributionsBloc,
child: const CreateContributionDialog(),
),
);
}
void _showStats() {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Statistiques'),
content: BlocBuilder<ContributionsBloc, ContributionsState>(
builder: (context, state) {
if (state is ContributionsStatsLoaded) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatRow('Total', state.stats['total'].toString()),
_buildStatRow('Payées', state.stats['payees'].toString()),
_buildStatRow('Non payées', state.stats['nonPayees'].toString()),
_buildStatRow('En retard', state.stats['enRetard'].toString()),
const Divider(),
_buildStatRow(
'Montant total',
_currencyFormat.format(state.stats['montantTotal']),
),
_buildStatRow(
'Montant payé',
_currencyFormat.format(state.stats['montantPaye']),
),
_buildStatRow(
'Taux recouvrement',
'${state.stats['tauxRecouvrement'].toStringAsFixed(1)}%',
),
],
);
}
return const AppLoadingWidget();
},
final contributionsBloc = context.read<ContributionsBloc>();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlocProvider.value(
value: contributionsBloc,
child: const MesStatistiquesCotisationsPage(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
}
}

View File

@@ -4,27 +4,42 @@ library cotisations_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import 'contributions_page.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/contributions_page.dart';
import 'package:unionflow_mobile_apps/features/members/bloc/membres_bloc.dart';
final _getIt = GetIt.instance;
/// Wrapper qui fournit le BLoC à la page des cotisations
/// Wrapper qui fournit les BLoCs à la page des cotisations (et au dialogue de création)
class CotisationsPageWrapper extends StatelessWidget {
const CotisationsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
// Charger les cotisations au démarrage
bloc.add(const LoadContributions());
return bloc;
},
return MultiBlocProvider(
providers: [
BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
bloc.add(const LoadContributions());
return bloc;
},
),
BlocProvider<MembresBloc>(
create: (context) => _getIt<MembresBloc>(),
),
],
child: const ContributionsPage(),
);
}
}
/// Alias pour la route /finances et références anglaises
class ContributionsPageWrapper extends StatelessWidget {
const ContributionsPageWrapper({super.key});
@override
Widget build(BuildContext context) => const CotisationsPageWrapper();
}

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/utils/logger.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
/// Page dédiée « Mes statistiques cotisations » : KPIs, graphiques et synthèse.
/// Données réelles via GET /api/cotisations/mes-cotisations/synthese + liste des cotisations.
class MesStatistiquesCotisationsPage extends StatefulWidget {
const MesStatistiquesCotisationsPage({super.key});
@override
State<MesStatistiquesCotisationsPage> createState() => _MesStatistiquesCotisationsPageState();
}
class _MesStatistiquesCotisationsPageState extends State<MesStatistiquesCotisationsPage> {
Map<String, dynamic>? _synthese;
List<ContributionModel>? _cotisations;
String? _error;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
// Charge uniquement la synthèse ; la liste est conservée dans l'état pour ne pas perdre l'onglet Toutes au retour.
context.read<ContributionsBloc>().add(const LoadContributionsStats());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Mes statistiques cotisations',
backgroundColor: ColorTokens.surface,
foregroundColor: ColorTokens.onSurface,
),
body: BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
if (state is ContributionsStatsLoaded) {
setState(() {
_synthese = state.stats;
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsLoaded) {
setState(() {
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsError) {
setState(() => _error = state.message);
}
},
child: RefreshIndicator(
onRefresh: () async {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
},
child: _buildBody(),
),
),
);
}
Widget _buildBody() {
if (_error != null) {
return Center(
child: AppErrorWidget(
message: _error!,
onRetry: () {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
context.read<ContributionsBloc>().add(const LoadContributions());
},
),
);
}
if (_synthese == null && _cotisations == null) {
return const Center(child: AppLoadingWidget());
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
const SizedBox(height: 24),
_buildKpiCards(),
const SizedBox(height: 20),
_buildTauxSection(),
const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildRepartitionChart(),
if (_cotisations != null && _cotisations!.isNotEmpty) const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildEvolutionSection(),
const SizedBox(height: 20),
_buildProchainesEcheances(),
const SizedBox(height: 32),
],
),
);
}
Widget _buildHeader() {
final annee = _synthese?['anneeEnCours'] is int
? _synthese!['anneeEnCours'] as int
: DateTime.now().year;
return Column(
children: [
Text(
'Synthèse $annee',
style: AppTypography.headerSmall.copyWith(fontSize: 20),
),
const SizedBox(height: 4),
Text(
'Votre situation cotisations',
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
),
],
);
}
Widget _buildKpiCards() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final enAttente = _synthese?['cotisationsEnAttente'] is int
? _synthese!['cotisationsEnAttente'] as int
: ((_synthese?['cotisationsEnAttente'] as num?)?.toInt() ?? 0);
final prochaineStr = _synthese?['prochaineEcheance']?.toString();
return Column(
children: [
Row(
children: [
Expanded(
child: _kpiCard(
'Montant dû',
_currencyFormat.format(montantDu),
icon: Icons.pending_actions_outlined,
color: montantDu > 0 ? UnionFlowColors.terracotta : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Payé cette année',
_currencyFormat.format(totalPayeAnnee),
icon: Icons.check_circle_outline,
color: UnionFlowColors.unionGreen,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _kpiCard(
'En attente',
'$enAttente',
icon: Icons.schedule,
color: enAttente > 0 ? UnionFlowColors.gold : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Prochaine échéance',
prochaineStr != null && prochaineStr.isNotEmpty && prochaineStr != 'null'
? _formatDate(prochaineStr)
: '',
icon: Icons.event,
color: UnionFlowColors.indigo,
),
),
],
),
],
);
}
Widget _kpiCard(String label, String value, {required IconData icon, required Color color}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: AppTypography.headerSmall.copyWith(color: color, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildTauxSection() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
final taux = total > 0 ? (totalPayeAnnee / total * 100) : 0.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Taux de paiement',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: (taux / 100).clamp(0.0, 1.0),
minHeight: 12,
backgroundColor: ColorTokens.onSurfaceVariant.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
taux >= 75 ? UnionFlowColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(
'${taux.toStringAsFixed(0)} %',
style: AppTypography.headerSmall.copyWith(color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w700),
),
Text('100 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
],
),
],
),
);
}
Widget _buildRepartitionChart() {
final paye = _cotisations!
.where((c) => c.statut == ContributionStatus.payee)
.fold<double>(0, (s, c) => s + (c.montantPaye ?? c.montant));
final du = _cotisations!
.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee)
.fold<double>(0, (s, c) => s + c.montant);
if (paye + du <= 0) return const SizedBox.shrink();
final sections = <PieChartSectionData>[];
if (paye > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.unionGreen,
value: paye,
title: 'Payé',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (du > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.terracotta,
value: du,
title: '',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (sections.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition Payé / Dû',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: sections,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_legendItem(UnionFlowColors.unionGreen, 'Payé', _currencyFormat.format(paye)),
_legendItem(UnionFlowColors.terracotta, '', _currencyFormat.format(du)),
],
),
],
),
);
}
Widget _legendItem(Color color, String label, String value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
],
),
],
);
}
Widget _buildEvolutionSection() {
final payees = _cotisations!.where((c) => c.statut == ContributionStatus.payee).toList();
if (payees.isEmpty) return const SizedBox.shrink();
final byMonth = <int, double>{};
for (final c in payees) {
final d = c.datePaiement ?? c.dateEcheance;
final month = d.month + d.year * 12;
byMonth[month] = (byMonth[month] ?? 0) + (c.montantPaye ?? c.montant);
}
final entries = byMonth.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
if (entries.isEmpty) return const SizedBox.shrink();
final dataMaxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final yMax = (dataMaxY * 1.1 + 1).clamp(1.0, double.infinity);
final yInterval = yMax / 4;
final spots = entries.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.value)).toList();
final n = spots.length;
final xInterval = n <= 5 ? 1.0 : (n - 1) / 4;
final xIntervalSafe = xInterval < 1 ? 1.0 : xInterval;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paiements par période',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(
LineChartData(
gridData: FlGridData(show: true, drawVerticalLine: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: yInterval,
getTitlesWidget: (v, _) => Text(_formatAxisAmount(v), style: const TextStyle(fontSize: 10)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: xIntervalSafe,
getTitlesWidget: (v, _) {
final i = v.round();
if (i >= 0 && i < entries.length) {
final k = entries[i].key;
final m = k % 12 == 0 ? 12 : k % 12;
final y = k % 12 == 0 ? (k ~/ 12) - 1 : (k ~/ 12);
return Text(_formatAxisPeriod(m, y), style: const TextStyle(fontSize: 10));
}
return const SizedBox.shrink();
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: true, border: Border(bottom: BorderSide(color: ColorTokens.outline), left: BorderSide(color: ColorTokens.outline))),
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: 0,
maxY: yMax,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: UnionFlowColors.unionGreen,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(show: true, color: UnionFlowColors.unionGreen.withOpacity(0.15)),
),
],
),
),
),
],
),
);
}
Widget _buildProchainesEcheances() {
final list = _cotisations ?? [];
final aRegler = list.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee).toList();
aRegler.sort((a, b) => a.dateEcheance.compareTo(b.dateEcheance));
final top = aRegler.take(5).toList();
if (top.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prochaines échéances à régler',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
...top.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDate(c.dateEcheance.toIso8601String()),
style: AppTypography.bodyTextSmall,
),
Text(
_currencyFormat.format(c.montant),
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: UnionFlowColors.terracotta,
),
),
],
),
)),
],
),
);
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
String _formatDate(String isoOrRaw) {
try {
final dt = DateTime.tryParse(isoOrRaw);
if (dt != null) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
}
} catch (e, st) {
AppLogger.warning('MesStatistiquesCotisations: format date invalide', tag: isoOrRaw);
}
return isoOrRaw;
}
String _formatShortAmount(double v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}k';
return v.toStringAsFixed(0);
}
/// Format court pour laxe Y : 0, 25 k, 50 k, 1 M — peu de libellés, lisibles.
String _formatAxisAmount(double v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)} M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)} k';
if (v < 1) return '0';
return v.toStringAsFixed(0);
}
String _monthShort(int m) {
const t = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return m >= 1 && m <= 12 ? t[m - 1] : '';
}
/// Libellé court pour laxe X : "Jan 25", "Avr 25" — peu de caractères.
String _formatAxisPeriod(int month, int year) {
final shortYear = year % 100;
return '${_monthShort(month)} $shortYear';
}
}

View File

@@ -3,13 +3,14 @@ library create_contribution_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import 'package:intl/intl.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../data/models/contribution_model.dart';
import '../../../members/bloc/membres_bloc.dart';
import '../../../members/bloc/membres_event.dart';
import '../../../members/bloc/membres_state.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateContributionDialog extends StatefulWidget {
@@ -25,15 +26,37 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
final _descriptionController = TextEditingController();
ContributionType _selectedType = ContributionType.mensuelle;
dynamic _selectedMembre;
MembreCompletModel? _me;
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
bool _isLoading = false;
bool _isInitLoading = true;
@override
void initState() {
super.initState();
// Charger la liste des membres
context.read<MembresBloc>().add(const LoadMembres());
_loadMe();
}
Future<void> _loadMe() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
if (mounted) {
setState(() {
_me = user;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateContributionDialog: chargement profil échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil. Réessayez.')),
);
}
}
}
@override
@@ -55,38 +78,21 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Sélection du membre
BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
if (state is MembresLoaded) {
return DropdownButtonFormField<dynamic>(
value: _selectedMembre,
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
),
items: state.membres.map((membre) {
return DropdownMenuItem(
value: membre,
child: Text('${membre.nom} ${membre.prenom}'),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedMembre = value;
});
},
validator: (value) {
if (value == null) {
return 'Veuillez sélectionner un membre';
}
return null;
},
);
}
return const CircularProgressIndicator();
},
),
// Utilisateur connecté
if (_isInitLoading)
const CircularProgressIndicator()
else if (_me != null)
TextFormField(
initialValue: '${_me!.prenom} ${_me!.nom}',
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: false, // Lecture seule
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
// Type de contribution
@@ -210,15 +216,15 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
}
}
void _createContribution() {
Future<void> _createContribution() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedMembre == null) {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez sélectionner un membre'),
content: Text('Profil non chargé'),
backgroundColor: Colors.red,
),
);
@@ -229,10 +235,31 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
_isLoading = true;
});
final membre = _me!;
String? organisationId = membre.organisationId?.trim().isNotEmpty == true
? membre.organisationId
: null;
String? organisationNom = membre.organisationNom;
if (organisationId == null || organisationId.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aucune organisation disponible. Le membre et l\'utilisateur connecté doivent être rattachés à une organisation.'),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading = false);
return;
}
final contribution = ContributionModel(
membreId: _selectedMembre!.id!,
membreNom: _selectedMembre!.nom,
membrePrenom: _selectedMembre!.prenom,
membreId: membre.id!,
membreNom: membre.nom,
membrePrenom: membre.prenom,
organisationId: organisationId,
organisationNom: organisationNom,
type: _selectedType,
annee: DateTime.now().year,
montant: double.parse(_montantController.text),

View File

@@ -1,13 +1,18 @@
/// Dialogue de paiement de contribution
/// Formulaire pour enregistrer un paiement de contribution
/// Formulaire pour enregistrer un paiement de contribution.
/// Pour Wave : appelle l'API Checkout, ouvre wave_launch_url (app Wave), retour automatique via deep link.
library payment_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:unionflow_mobile_apps/core/di/injection.dart';
import 'package:unionflow_mobile_apps/shared/constants/payment_method_assets.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../data/models/contribution_model.dart';
import '../../domain/repositories/contribution_repository.dart';
/// Dialogue de paiement de contribution
class PaymentDialog extends StatefulWidget {
@@ -27,22 +32,24 @@ class _PaymentDialogState extends State<PaymentDialog> {
final _montantController = TextEditingController();
final _referenceController = TextEditingController();
final _notesController = TextEditingController();
final _wavePhoneController = TextEditingController();
PaymentMethod _selectedMethode = PaymentMethod.waveMoney;
DateTime _datePaiement = DateTime.now();
bool _waveLoading = false;
@override
void initState() {
super.initState();
// Pré-remplir avec le montant restant
_montantController.text = widget.cotisation.montantRestant.toStringAsFixed(0);
}
@override
void dispose() {
_montantController.dispose();
_referenceController.dispose();
_notesController.dispose();
_wavePhoneController.dispose();
super.dispose();
}
@@ -199,11 +206,15 @@ class _PaymentDialogState extends State<PaymentDialog> {
prefixIcon: Icon(Icons.payment),
),
items: PaymentMethod.values.map((methode) {
return DropdownMenuItem(
return DropdownMenuItem<PaymentMethod>(
value: methode,
child: Row(
children: [
Icon(_getMethodeIcon(methode), size: 20),
PaymentMethodIcon(
paymentMethodCode: methode.code,
width: 24,
height: 24,
),
const SizedBox(width: 8),
Text(_getMethodeLabel(methode)),
],
@@ -216,8 +227,28 @@ class _PaymentDialogState extends State<PaymentDialog> {
});
},
),
if (_selectedMethode == PaymentMethod.waveMoney) ...[
const SizedBox(height: 12),
TextFormField(
controller: _wavePhoneController,
decoration: const InputDecoration(
labelText: 'Numéro Wave (9 chiffres) *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone_android),
hintText: 'Ex: 771234567',
),
keyboardType: TextInputType.number,
validator: (value) {
if (_selectedMethode != PaymentMethod.waveMoney) return null;
final digits = value?.replaceAll(RegExp(r'\D'), '') ?? '';
if (digits.length < 9) {
return 'Numéro Wave requis (9 chiffres) pour payer via Wave';
}
return null;
},
),
],
const SizedBox(height: 12),
// Date de paiement
InkWell(
onTap: () => _selectDate(context),
@@ -278,12 +309,20 @@ class _PaymentDialogState extends State<PaymentDialog> {
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submitForm,
onPressed: _waveLoading ? null : _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
),
child: const Text('Enregistrer le paiement'),
child: _waveLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Text(_selectedMethode == PaymentMethod.waveMoney
? 'Ouvrir Wave pour payer'
: 'Enregistrer le paiement'),
),
],
),
@@ -354,42 +393,80 @@ class _PaymentDialogState extends State<PaymentDialog> {
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final montant = double.parse(_montantController.text);
// Créer la cotisation mise à jour
widget.cotisation.copyWith(
montantPaye: (widget.cotisation.montantPaye ?? 0) + montant,
datePaiement: _datePaiement,
methodePaiement: _selectedMethode,
referencePaiement: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
statut: (widget.cotisation.montantPaye ?? 0) + montant >= widget.cotisation.montant
? ContributionStatus.payee
: ContributionStatus.partielle,
);
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) return;
// Envoyer l'événement au BLoC
context.read<ContributionsBloc>().add(RecordPayment(
contributionId: widget.cotisation.id!,
montant: montant,
methodePaiement: _selectedMethode,
datePaiement: _datePaiement,
reference: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
));
// Fermer le dialogue
Navigator.pop(context);
// Afficher un message de succès
if (_selectedMethode == PaymentMethod.waveMoney) {
await _submitWavePayment();
return;
}
final montant = double.parse(_montantController.text);
// LUI est rafraîchie par le BLoC après RecordPayment ; pas besoin de copyWith local.
context.read<ContributionsBloc>().add(RecordPayment(
contributionId: widget.cotisation.id!,
montant: montant,
methodePaiement: _selectedMethode,
datePaiement: _datePaiement,
reference: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement enregistré avec succès'),
backgroundColor: Colors.green,
),
);
}
/// Initie le paiement Wave : appel API Checkout, ouverture de l'app Wave, retour via deep link.
Future<void> _submitWavePayment() async {
if (widget.cotisation.id == null || widget.cotisation.id!.isEmpty) return;
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
if (phone.length < 9) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement enregistré avec succès'),
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)'), backgroundColor: Colors.orange),
);
return;
}
setState(() => _waveLoading = true);
try {
final repo = getIt<IContributionRepository>();
final result = await repo.initierPaiementEnLigne(
cotisationId: widget.cotisation.id!,
methodePaiement: 'WAVE',
numeroTelephone: phone,
);
final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl;
if (url.isEmpty) {
throw Exception('URL Wave non reçue');
}
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
await launchUrl(uri);
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor: Colors.green,
),
);
context.read<ContributionsBloc>().add(const LoadContributions());
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Wave: ${e.toString().replaceFirst('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) setState(() => _waveLoading = false);
}
}
}

View File

@@ -11,8 +11,7 @@ class DashboardConfig {
static const String primaryColorHex = '#4169E1'; // Bleu Roi
static const String secondaryColorHex = '#008B8B'; // Bleu Pétrole
// Configuration des données
static const bool useMockData = false;
// Configuration des données (toujours API réelle, pas de données fictives)
static String get apiBaseUrl => AppConfig.apiBaseUrl;
static const Duration networkTimeout = Duration(seconds: 30);
@@ -282,9 +281,6 @@ class DashboardConfig {
};
// Méthodes utilitaires
static bool get isDevelopment => useMockData;
static bool get isProduction => !useMockData;
static String get fullVersion => '$version+$buildNumber';
static Duration get effectiveRefreshInterval =>

View File

@@ -1,400 +0,0 @@
import 'dart:convert';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/dashboard_stats_model.dart';
import '../../config/dashboard_config.dart';
/// Gestionnaire de cache avancé pour le Dashboard
class DashboardCacheManager {
static const String _keyPrefix = 'dashboard_cache_';
static const String _keyDashboardData = '${_keyPrefix}data';
static const String _keyDashboardStats = '${_keyPrefix}stats';
static const String _keyRecentActivities = '${_keyPrefix}activities';
static const String _keyUpcomingEvents = '${_keyPrefix}events';
static const String _keyLastUpdate = '${_keyPrefix}last_update';
static const String _keyUserPreferences = '${_keyPrefix}user_prefs';
SharedPreferences? _prefs;
final Map<String, dynamic> _memoryCache = {};
final Map<String, DateTime> _cacheTimestamps = {};
Timer? _cleanupTimer;
/// Initialise le gestionnaire de cache
Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
_startCleanupTimer();
await _loadMemoryCache();
}
/// Démarre le timer de nettoyage automatique
void _startCleanupTimer() {
_cleanupTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => _cleanupExpiredCache(),
);
}
/// Charge le cache en mémoire au démarrage
Future<void> _loadMemoryCache() async {
if (_prefs == null) return;
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
for (final key in keys) {
final value = _prefs!.getString(key);
if (value != null) {
try {
final data = jsonDecode(value);
_memoryCache[key] = data;
// Charger le timestamp si disponible
final timestampKey = '${key}_timestamp';
final timestamp = _prefs!.getInt(timestampKey);
if (timestamp != null) {
_cacheTimestamps[key] = DateTime.fromMillisecondsSinceEpoch(timestamp);
}
} catch (e) {
// Supprimer les données corrompues
await _prefs!.remove(key);
}
}
}
}
/// Sauvegarde les données complètes du dashboard
Future<void> cacheDashboardData(
DashboardDataModel data,
String organizationId,
String userId,
) async {
final key = '${_keyDashboardData}_${organizationId}_$userId';
await _cacheData(key, data.toJson());
}
/// Récupère les données complètes du dashboard
Future<DashboardDataModel?> getCachedDashboardData(
String organizationId,
String userId,
) async {
final key = '${_keyDashboardData}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null) {
try {
return DashboardDataModel.fromJson(data);
} catch (e) {
// Supprimer les données corrompues
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les statistiques du dashboard
Future<void> cacheDashboardStats(
DashboardStatsModel stats,
String organizationId,
String userId,
) async {
final key = '${_keyDashboardStats}_${organizationId}_$userId';
await _cacheData(key, stats.toJson());
}
/// Récupère les statistiques du dashboard
Future<DashboardStatsModel?> getCachedDashboardStats(
String organizationId,
String userId,
) async {
final key = '${_keyDashboardStats}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null) {
try {
return DashboardStatsModel.fromJson(data);
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les activités récentes
Future<void> cacheRecentActivities(
List<RecentActivityModel> activities,
String organizationId,
String userId,
) async {
final key = '${_keyRecentActivities}_${organizationId}_$userId';
final data = activities.map((activity) => activity.toJson()).toList();
await _cacheData(key, data);
}
/// Récupère les activités récentes
Future<List<RecentActivityModel>?> getCachedRecentActivities(
String organizationId,
String userId,
) async {
final key = '${_keyRecentActivities}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null && data is List) {
try {
return data
.map((item) => RecentActivityModel.fromJson(item))
.toList();
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les événements à venir
Future<void> cacheUpcomingEvents(
List<UpcomingEventModel> events,
String organizationId,
String userId,
) async {
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
final data = events.map((event) => event.toJson()).toList();
await _cacheData(key, data);
}
/// Récupère les événements à venir
Future<List<UpcomingEventModel>?> getCachedUpcomingEvents(
String organizationId,
String userId,
) async {
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null && data is List) {
try {
return data
.map((item) => UpcomingEventModel.fromJson(item))
.toList();
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les préférences utilisateur
Future<void> cacheUserPreferences(
Map<String, dynamic> preferences,
String userId,
) async {
final key = '${_keyUserPreferences}_$userId';
await _cacheData(key, preferences);
}
/// Récupère les préférences utilisateur
Future<Map<String, dynamic>?> getCachedUserPreferences(String userId) async {
final key = '${_keyUserPreferences}_$userId';
final data = await _getCachedData(key);
if (data != null && data is Map<String, dynamic>) {
return data;
}
return null;
}
/// Méthode générique pour sauvegarder des données
Future<void> _cacheData(String key, dynamic data) async {
if (_prefs == null) return;
try {
final jsonString = jsonEncode(data);
await _prefs!.setString(key, jsonString);
// Sauvegarder le timestamp
final timestamp = DateTime.now().millisecondsSinceEpoch;
await _prefs!.setInt('${key}_timestamp', timestamp);
// Mettre à jour le cache mémoire
_memoryCache[key] = data;
_cacheTimestamps[key] = DateTime.now();
} catch (e) {
// Erreur de sérialisation, ignorer
}
}
/// Méthode générique pour récupérer des données
Future<dynamic> _getCachedData(String key) async {
// Vérifier d'abord le cache mémoire
if (_memoryCache.containsKey(key)) {
if (_isCacheValid(key)) {
return _memoryCache[key];
} else {
// Cache expiré, le supprimer
await _removeCachedData(key);
return null;
}
}
// Vérifier le cache persistant
if (_prefs == null) return null;
final jsonString = _prefs!.getString(key);
if (jsonString != null) {
try {
final data = jsonDecode(jsonString);
// Vérifier la validité du cache
if (_isCacheValid(key)) {
// Charger en mémoire pour les prochains accès
_memoryCache[key] = data;
return data;
} else {
// Cache expiré, le supprimer
await _removeCachedData(key);
return null;
}
} catch (e) {
// Données corrompues, les supprimer
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Vérifie si le cache est encore valide
bool _isCacheValid(String key) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) {
// Essayer de récupérer le timestamp depuis SharedPreferences
final timestampMs = _prefs?.getInt('${key}_timestamp');
if (timestampMs != null) {
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestampMs);
_cacheTimestamps[key] = cacheTime;
return DateTime.now().difference(cacheTime) < DashboardConfig.cacheExpiration;
}
return false;
}
return DateTime.now().difference(timestamp) < DashboardConfig.cacheExpiration;
}
/// Supprime des données du cache
Future<void> _removeCachedData(String key) async {
_memoryCache.remove(key);
_cacheTimestamps.remove(key);
if (_prefs != null) {
await _prefs!.remove(key);
await _prefs!.remove('${key}_timestamp');
}
}
/// Nettoie le cache expiré
Future<void> _cleanupExpiredCache() async {
final keysToRemove = <String>[];
for (final key in _cacheTimestamps.keys) {
if (!_isCacheValid(key)) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
await _removeCachedData(key);
}
}
/// Vide tout le cache
Future<void> clearCache() async {
_memoryCache.clear();
_cacheTimestamps.clear();
if (_prefs != null) {
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
for (final key in keys) {
await _prefs!.remove(key);
}
}
}
/// Vide le cache pour un utilisateur spécifique
Future<void> clearUserCache(String organizationId, String userId) async {
final userKeys = [
'${_keyDashboardData}_${organizationId}_$userId',
'${_keyDashboardStats}_${organizationId}_$userId',
'${_keyRecentActivities}_${organizationId}_$userId',
'${_keyUpcomingEvents}_${organizationId}_$userId',
'${_keyUserPreferences}_$userId',
];
for (final key in userKeys) {
await _removeCachedData(key);
}
}
/// Obtient les statistiques du cache
Map<String, dynamic> getCacheStats() {
final totalKeys = _memoryCache.length;
final validKeys = _cacheTimestamps.keys.where(_isCacheValid).length;
final expiredKeys = totalKeys - validKeys;
return {
'totalKeys': totalKeys,
'validKeys': validKeys,
'expiredKeys': expiredKeys,
'memoryUsage': _calculateMemoryUsage(),
'oldestEntry': _getOldestEntryAge(),
'newestEntry': _getNewestEntryAge(),
};
}
/// Calcule l'utilisation mémoire approximative
int _calculateMemoryUsage() {
int totalSize = 0;
for (final data in _memoryCache.values) {
try {
totalSize += jsonEncode(data).length;
} catch (e) {
// Ignorer les erreurs de sérialisation
}
}
return totalSize;
}
/// Obtient l'âge de l'entrée la plus ancienne
Duration? _getOldestEntryAge() {
if (_cacheTimestamps.isEmpty) return null;
final oldestTimestamp = _cacheTimestamps.values
.reduce((a, b) => a.isBefore(b) ? a : b);
return DateTime.now().difference(oldestTimestamp);
}
/// Obtient l'âge de l'entrée la plus récente
Duration? _getNewestEntryAge() {
if (_cacheTimestamps.isEmpty) return null;
final newestTimestamp = _cacheTimestamps.values
.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(newestTimestamp);
}
/// Libère les ressources
void dispose() {
_cleanupTimer?.cancel();
_memoryCache.clear();
_cacheTimestamps.clear();
}
}

View File

@@ -1,24 +1,36 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/utils/logger.dart';
import '../models/dashboard_stats_model.dart';
import '../../../../core/network/dio_client.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
abstract class DashboardRemoteDataSource {
Future<DashboardDataModel> getDashboardData(String organizationId, String userId);
/// Dashboard personnel du membre connecté (sans organisationId). GET /api/dashboard/membre/me
Future<MembreDashboardSyntheseModel> getMemberDashboardData();
/// Synthèse des cotisations du membre connecté. GET /api/cotisations/mes-cotisations/synthese
/// Utilisé en fallback quand les montants de getMemberDashboardData() sont à 0.
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Compte adhérent unifié (soldes, crédits, capacité d'emprunt). GET /api/membres/mon-compte
Future<CompteAdherentModel> getCompteAdherent();
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId);
Future<List<RecentActivityModel>> getRecentActivities(String organizationId, String userId, {int limit = 10});
Future<List<UpcomingEventModel>> getUpcomingEvents(String organizationId, String userId, {int limit = 5});
}
@Injectable(as: DashboardRemoteDataSource)
class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
final DioClient dioClient;
final ApiClient apiClient;
DashboardRemoteDataSourceImpl({required this.dioClient});
DashboardRemoteDataSourceImpl(this.apiClient);
@override
Future<DashboardDataModel> getDashboardData(String organizationId, String userId) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/data',
queryParameters: {
'organizationId': organizationId,
@@ -32,16 +44,77 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load dashboard data: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<MembreDashboardSyntheseModel> getMemberDashboardData() async {
try {
final response = await apiClient.get('/api/dashboard/membre/me');
if (response.statusCode == 200) {
return MembreDashboardSyntheseModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load member dashboard: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await apiClient.get('/api/cotisations/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
return response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
}
return null;
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<CompteAdherentModel> getCompteAdherent() async {
try {
final response = await apiClient.get('/api/membres/mon-compte');
if (response.statusCode == 200) {
return CompteAdherentModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load adherent account: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/stats',
queryParameters: {
'organizationId': organizationId,
@@ -55,9 +128,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load dashboard stats: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e, stackTrace: st);
rethrow;
}
}
@@ -68,7 +143,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
int limit = 10,
}) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/activities',
queryParameters: {
'organizationId': organizationId,
@@ -84,9 +159,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load recent activities: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e, stackTrace: st);
rethrow;
}
}
@@ -97,7 +174,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
int limit = 5,
}) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/events/upcoming',
queryParameters: {
'organizationId': organizationId,
@@ -113,9 +190,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load upcoming events: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e, stackTrace: st);
rethrow;
}
}
}

View File

@@ -0,0 +1,72 @@
/// Modèle pour le "compte adhérent" unifié (GET /api/membres/mon-compte).
class CompteAdherentModel {
final String numeroMembre;
final String nomComplet;
final String? organisationNom;
final String? dateAdhesion;
final String statutCompte;
final double soldeCotisations;
final double soldeEpargne;
final double soldeBloque;
final double soldeTotalDisponible;
final double encoursCreditTotal;
final double capaciteEmprunt;
final int nombreCotisationsPayees;
final int nombreCotisationsTotal;
final int nombreCotisationsEnRetard;
final int? tauxEngagement;
final int nombreComptesEpargne;
final String dateCalcul;
const CompteAdherentModel({
required this.numeroMembre,
required this.nomComplet,
this.organisationNom,
this.dateAdhesion,
this.statutCompte = 'ACTIF',
this.soldeCotisations = 0,
this.soldeEpargne = 0,
this.soldeBloque = 0,
this.soldeTotalDisponible = 0,
this.encoursCreditTotal = 0,
this.capaciteEmprunt = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.nombreCotisationsEnRetard = 0,
this.tauxEngagement,
this.nombreComptesEpargne = 0,
required this.dateCalcul,
});
factory CompteAdherentModel.fromJson(Map<String, dynamic> json) {
return CompteAdherentModel(
numeroMembre: json['numeroMembre'] as String? ?? 'N/A',
nomComplet: json['nomComplet'] as String? ?? '',
organisationNom: json['organisationNom'] as String?,
dateAdhesion: json['dateAdhesion'] as String?,
statutCompte: json['statutCompte'] as String? ?? 'ACTIF',
soldeCotisations: _toDouble(json['soldeCotisations']),
soldeEpargne: _toDouble(json['soldeEpargne']),
soldeBloque: _toDouble(json['soldeBloque']),
soldeTotalDisponible: _toDouble(json['soldeTotalDisponible']),
encoursCreditTotal: _toDouble(json['encoursCreditTotal']),
capaciteEmprunt: _toDouble(json['capaciteEmprunt']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ?? 0,
nombreCotisationsEnRetard: (json['nombreCotisationsEnRetard'] as num?)?.toInt() ?? 0,
tauxEngagement: (json['tauxEngagement'] as num?)?.toInt(),
nombreComptesEpargne: (json['nombreComptesEpargne'] as num?)?.toInt() ?? 0,
dateCalcul: json['dateCalcul'] as String? ?? '',
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
}

View File

@@ -17,6 +17,8 @@ class DashboardStatsModel extends Equatable {
final double monthlyGrowth;
final double engagementRate;
final DateTime lastUpdated;
final int? totalOrganizations;
final Map<String, int>? organizationTypeDistribution;
const DashboardStatsModel({
required this.totalMembers,
@@ -30,6 +32,8 @@ class DashboardStatsModel extends Equatable {
required this.monthlyGrowth,
required this.engagementRate,
required this.lastUpdated,
this.totalOrganizations,
this.organizationTypeDistribution,
});
factory DashboardStatsModel.fromJson(Map<String, dynamic> json) =>
@@ -63,6 +67,8 @@ class DashboardStatsModel extends Equatable {
monthlyGrowth,
engagementRate,
lastUpdated,
totalOrganizations,
organizationTypeDistribution,
];
}

View File

@@ -20,6 +20,11 @@ DashboardStatsModel _$DashboardStatsModelFromJson(Map<String, dynamic> json) =>
monthlyGrowth: (json['monthlyGrowth'] as num).toDouble(),
engagementRate: (json['engagementRate'] as num).toDouble(),
lastUpdated: DateTime.parse(json['lastUpdated'] as String),
totalOrganizations: (json['totalOrganizations'] as num?)?.toInt(),
organizationTypeDistribution:
(json['organizationTypeDistribution'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
);
Map<String, dynamic> _$DashboardStatsModelToJson(
@@ -36,6 +41,8 @@ Map<String, dynamic> _$DashboardStatsModelToJson(
'monthlyGrowth': instance.monthlyGrowth,
'engagementRate': instance.engagementRate,
'lastUpdated': instance.lastUpdated.toIso8601String(),
'totalOrganizations': instance.totalOrganizations,
'organizationTypeDistribution': instance.organizationTypeDistribution,
};
RecentActivityModel _$RecentActivityModelFromJson(Map<String, dynamic> json) =>

View File

@@ -11,6 +11,8 @@ class MembreDashboardSyntheseModel {
final double totalCotisationsPayeesToutTemps;
/// Nombre de cotisations payées (pour carte « Cotisations »).
final int nombreCotisationsPayees;
/// Nombre total de cotisations (toutes années, tous statuts).
final int nombreCotisationsTotal;
final String statutCotisations;
final int? tauxCotisationsPerso;
final double monSoldeEpargne;
@@ -32,6 +34,7 @@ class MembreDashboardSyntheseModel {
this.totalCotisationsPayeesAnnee = 0,
this.totalCotisationsPayeesToutTemps = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.statutCotisations = 'À jour',
this.tauxCotisationsPerso,
this.monSoldeEpargne = 0,
@@ -55,6 +58,8 @@ class MembreDashboardSyntheseModel {
totalCotisationsPayeesAnnee: _toDouble(json['totalCotisationsPayeesAnnee']),
totalCotisationsPayeesToutTemps: _toDouble(json['totalCotisationsPayeesToutTemps']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ??
(json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
statutCotisations: json['statutCotisations'] as String? ?? 'À jour',
tauxCotisationsPerso: (json['tauxCotisationsPerso'] as num?)?.toInt(),
monSoldeEpargne: _toDouble(json['monSoldeEpargne']),
@@ -70,6 +75,7 @@ class MembreDashboardSyntheseModel {
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();

View File

@@ -1,10 +1,12 @@
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import '../../domain/entities/dashboard_entity.dart';
import '../../domain/entities/compte_adherent_entity.dart';
import '../../domain/repositories/dashboard_repository.dart';
import '../datasources/dashboard_remote_datasource.dart';
import '../models/dashboard_stats_model.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
@@ -19,6 +21,21 @@ class DashboardRepositoryImpl implements DashboardRepository {
required this.networkInfo,
});
@override
Future<Either<Failure, CompteAdherentEntity>> getCompteAdherent() async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
final model = await remoteDataSource.getCompteAdherent();
return Right(_mapCompteToEntity(model));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, DashboardEntity>> getDashboardData(
String organizationId,
@@ -31,9 +48,32 @@ class DashboardRepositoryImpl implements DashboardRepository {
// Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me)
final useMemberDashboard = organizationId.trim().isEmpty;
if (useMemberDashboard) {
final synthese = await remoteDataSource.getMemberDashboardData();
return Right(_mapMemberSyntheseToEntity(synthese, userId));
// Chargement parallèle de la synthèse et du compte adhérent unifié
final results = await Future.wait([
remoteDataSource.getMemberDashboardData(),
remoteDataSource.getCompteAdherent(),
]);
final synthese = results[0] as MembreDashboardSyntheseModel;
final compteModel = results[1] as CompteAdherentModel;
// Fallback : si les montants sont à zéro mais qu'il y a des cotisations,
// on complète avec /api/cotisations/mes-cotisations/synthese
Map<String, dynamic>? cotSynthese;
if (synthese.totalCotisationsPayeesToutTemps == 0 ||
synthese.tauxCotisationsPerso == null ||
(synthese.tauxCotisationsPerso ?? 0) == 0) {
cotSynthese = await remoteDataSource.getMesCotisationsSynthese();
}
return Right(_mapMemberSyntheseToEntity(
synthese,
userId,
cotSynthese: cotSynthese,
compteModel: compteModel,
));
}
final dashboardData = await remoteDataSource.getDashboardData(organizationId, userId);
return Right(_mapToEntity(dashboardData));
} on ServerException catch (e) {
@@ -43,24 +83,65 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
/// Construit une DashboardEntity à partir de la synthèse membre (même structure pour réutiliser l'UI).
DashboardEntity _mapMemberSyntheseToEntity(MembreDashboardSyntheseModel s, String userId) {
/// Construit une DashboardEntity à partir de la synthèse membre.
/// [cotSynthese] est optionnel : utilisé en fallback quand les montants du dashboard
/// membre sont à zéro (incohérence backend entre /api/dashboard/membre/me
/// et /api/cotisations/mes-cotisations/synthese).
DashboardEntity _mapMemberSyntheseToEntity(
MembreDashboardSyntheseModel s,
String userId, {
Map<String, dynamic>? cotSynthese,
CompteAdherentModel? compteModel,
}) {
final now = DateTime.now();
// Contribution Totale = cotisations payées tout temps ; MON SOLDE TOTAL = cotisations tout temps + épargne
final totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
// ------------------------------------------------------------------
// Montant des cotisations payées tout temps
// ------------------------------------------------------------------
double totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
if (totalCotisationsToutTemps == 0 && cotSynthese != null) {
// totalPayeAnnee = montant payé sur l'année en cours (meilleure approximation disponible)
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
if (totalPayeAnnee > 0) totalCotisationsToutTemps = totalPayeAnnee;
}
// ------------------------------------------------------------------
// MON SOLDE TOTAL = cotisations payées + épargne
// ------------------------------------------------------------------
final monSoldeTotal = totalCotisationsToutTemps + s.monSoldeEpargne;
// ------------------------------------------------------------------
// Taux d'engagement (en %)
// Priorité : tauxParticipationPerso > tauxCotisationsPerso > calculé depuis cotSynthese
// ------------------------------------------------------------------
int? tauxBrut = s.tauxParticipationPerso ?? s.tauxCotisationsPerso;
double engagementRate = (tauxBrut ?? 0) / 100.0;
if (engagementRate == 0 && cotSynthese != null) {
final montantDu = _toDouble(cotSynthese['montantDu']);
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
if (total > 0) engagementRate = totalPayeAnnee / total;
}
// ------------------------------------------------------------------
// Nombre de cotisations — utilize NEW nombreCotisationsTotal if available
// ------------------------------------------------------------------
final int nombreCotisations = s.nombreCotisationsTotal > 0
? s.nombreCotisationsTotal
: s.nombreCotisationsPayees;
final stats = DashboardStatsEntity(
totalMembers: 0,
activeMembers: 0,
totalEvents: 0,
upcomingEvents: s.evenementsAVenir,
totalContributions: s.nombreCotisationsPayees,
totalContributions: nombreCotisations,
totalContributionAmount: monSoldeTotal,
contributionsAmountOnly: totalCotisationsToutTemps,
pendingRequests: 0,
completedProjects: 0,
monthlyGrowth: s.evolutionEpargneNombre,
engagementRate: ((s.tauxParticipationPerso ?? s.tauxCotisationsPerso) ?? 0) / 100.0,
engagementRate: engagementRate,
lastUpdated: now,
totalOrganizations: null,
organizationTypeDistribution: null,
@@ -69,10 +150,20 @@ class DashboardRepositoryImpl implements DashboardRepository {
stats: stats,
recentActivities: const [],
upcomingEvents: const [],
userPreferences: <String, dynamic>{},
userPreferences: const <String, dynamic>{},
organizationId: '',
userId: userId,
monCompte: compteModel != null ? _mapCompteToEntity(compteModel) : null,
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
@override
@@ -142,6 +233,28 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
CompteAdherentEntity _mapCompteToEntity(CompteAdherentModel model) {
return CompteAdherentEntity(
numeroMembre: model.numeroMembre,
nomComplet: model.nomComplet,
organisationNom: model.organisationNom,
dateAdhesion: model.dateAdhesion != null ? DateTime.tryParse(model.dateAdhesion!) : null,
statutCompte: model.statutCompte,
soldeCotisations: model.soldeCotisations,
soldeEpargne: model.soldeEpargne,
soldeBloque: model.soldeBloque,
soldeTotalDisponible: model.soldeTotalDisponible,
encoursCreditTotal: model.encoursCreditTotal,
capaciteEmprunt: model.capaciteEmprunt,
nombreCotisationsPayees: model.nombreCotisationsPayees,
nombreCotisationsTotal: model.nombreCotisationsTotal,
nombreCotisationsEnRetard: model.nombreCotisationsEnRetard,
engagementRate: (model.tauxEngagement ?? 0) / 100.0,
nombreComptesEpargne: model.nombreComptesEpargne,
dateCalcul: DateTime.tryParse(model.dateCalcul) ?? DateTime.now(),
);
}
// Mappers
DashboardEntity _mapToEntity(DashboardDataModel model) {
return DashboardEntity(

Some files were not shown because too many files have changed in this diff Show More