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