diff --git a/android/app/build.gradle b/android/app/build.gradle index 3b53302..a20812f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,10 +7,11 @@ plugins { android { namespace = "dev.lions.unionflow_mobile_apps" - compileSdk = 35 + compileSdk = 36 ndkVersion = flutter.ndkVersion compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -53,3 +54,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6b2ad5e..0d9c2e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -34,13 +34,6 @@ - - - - - - - diff --git a/android/app/src/main/kotlin/dev/lions/unionflow_mobile_apps/MainActivity.kt b/android/app/src/main/kotlin/dev/lions/unionflow_mobile_apps/MainActivity.kt index 2eb5899..1f4ee59 100644 --- a/android/app/src/main/kotlin/dev/lions/unionflow_mobile_apps/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/lions/unionflow_mobile_apps/MainActivity.kt @@ -1,29 +1,12 @@ package dev.lions.unionflow_mobile_apps import android.content.Intent -import android.os.Bundle import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - handleIntent(intent) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - handleIntent(intent) - } - - private fun handleIntent(intent: Intent?) { - if (intent?.action == Intent.ACTION_VIEW) { - val data = intent.data - if (data != null && data.scheme == "dev.lions.unionflow-mobile") { - // L'intent sera automatiquement traité par flutter_appauth - android.util.Log.d("MainActivity", "Deep link reçu: $data") - } - } } } diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index b7247b4..f9213f7 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -9,8 +9,7 @@ - 192.168.1.4 - localhost + 192.168.1.9 localhost 10.0.2.2 127.0.0.1 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c85cfe..348c409 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index b9e43bd..cb7d7dd 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/l10n.yaml b/l10n.yaml index 1363b25..b8c0e90 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,4 +1,5 @@ arb-dir: lib/l10n template-arb-file: app_fr.arb output-localization-file: app_localizations.dart +output-dir: lib/l10n diff --git a/lib/app/app.dart b/lib/app/app.dart index b4090bb..eed9bbe 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -7,9 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../l10n/app_localizations.dart'; import '../shared/design_system/theme/app_theme_sophisticated.dart'; import '../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../features/organizations/bloc/org_switcher_bloc.dart'; import '../core/l10n/locale_provider.dart'; import '../core/theme/theme_provider.dart'; import '../core/di/injection.dart'; @@ -39,6 +40,9 @@ class UnionFlowApp extends StatelessWidget { BlocProvider( create: (context) => getIt()..add(const AuthStatusChecked()), ), + BlocProvider( + create: (context) => getIt(), + ), ], child: Consumer2( builder: (context, locale, theme, child) { diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 2a2f0ad..dd0ef7c 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -10,4 +10,4 @@ final GetIt getIt = GetIt.instance; preferRelativeImports: true, // default asExtension: true, // default ) -void configureDependencies() => getIt.init(); +Future configureDependencies() => getIt.init(); diff --git a/lib/core/di/injection_container.dart b/lib/core/di/injection_container.dart index 619f451..1fc214d 100644 --- a/lib/core/di/injection_container.dart +++ b/lib/core/di/injection_container.dart @@ -10,7 +10,7 @@ final GetIt sl = getIt; /// Initialise toutes les dépendances de l'application Future initializeDependencies() async { - configureDependencies(); + await configureDependencies(); } /// Nettoie toutes les dépendances (optionnel, pour les tests) diff --git a/lib/core/navigation/adaptive_navigation.dart b/lib/core/navigation/adaptive_navigation.dart deleted file mode 100644 index 1e5ad93..0000000 --- a/lib/core/navigation/adaptive_navigation.dart +++ /dev/null @@ -1,563 +0,0 @@ -/// Système de navigation adaptatif basé sur les rôles -/// Navigation qui s'adapte selon les permissions et rôles utilisateurs -library adaptive_navigation; - -import 'package:flutter/material.dart'; -import '../../shared/design_system/tokens/app_colors.dart'; -import '../../shared/design_system/tokens/color_tokens.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 '../../features/authentication/data/models/permission_matrix.dart'; -import '../../shared/widgets/adaptive_widget.dart'; - -/// Élément de navigation adaptatif -class AdaptiveNavigationItem { - /// Icône de l'élément - final IconData icon; - - /// Icône sélectionnée (optionnelle) - final IconData? selectedIcon; - - /// Libellé de l'élément - final String label; - - /// Route de destination - final String route; - - /// Permissions requises pour afficher cet élément - final List requiredPermissions; - - /// Rôles minimum requis - final UserRole? minimumRole; - - /// Badge de notification (optionnel) - final String? badge; - - /// Couleur personnalisée (optionnelle) - final Color? color; - - const AdaptiveNavigationItem({ - required this.icon, - this.selectedIcon, - required this.label, - required this.route, - this.requiredPermissions = const [], - this.minimumRole, - this.badge, - this.color, - }); -} - -/// Drawer de navigation adaptatif -class AdaptiveNavigationDrawer extends StatelessWidget { - /// Callback de navigation - final Function(String route) onNavigate; - - /// Callback de déconnexion - final VoidCallback onLogout; - - /// Éléments de navigation personnalisés - final List? customItems; - - const AdaptiveNavigationDrawer({ - super.key, - required this.onNavigate, - required this.onLogout, - this.customItems, - }); - - @override - Widget build(BuildContext context) { - return AdaptiveWidget( - roleWidgets: { - UserRole.superAdmin: () => _buildSuperAdminDrawer(context), - UserRole.orgAdmin: () => _buildOrgAdminDrawer(context), - UserRole.moderator: () => _buildModeratorDrawer(context), - UserRole.activeMember: () => _buildActiveMemberDrawer(context), - UserRole.simpleMember: () => _buildSimpleMemberDrawer(context), - UserRole.visitor: () => _buildVisitorDrawer(context), - }, - fallbackWidget: _buildBasicDrawer(context), - loadingWidget: _buildLoadingDrawer(context), - ); - } - - /// Drawer pour Super Admin - Widget _buildSuperAdminDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.dashboard, - label: 'Command Center', - route: '/dashboard', - requiredPermissions: [PermissionMatrix.SYSTEM_ADMIN], - ), - const AdaptiveNavigationItem( - icon: Icons.business, - label: 'Organisations', - route: '/organizations', - requiredPermissions: [PermissionMatrix.ORG_CREATE], - ), - const AdaptiveNavigationItem( - icon: Icons.people, - label: 'Utilisateurs Globaux', - route: '/global-users', - requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.settings, - label: 'Administration', - route: '/system-admin', - requiredPermissions: [PermissionMatrix.SYSTEM_CONFIG], - ), - const AdaptiveNavigationItem( - icon: Icons.analytics, - label: 'Analytics', - route: '/analytics', - requiredPermissions: [PermissionMatrix.DASHBOARD_ANALYTICS], - ), - const AdaptiveNavigationItem( - icon: Icons.security, - label: 'Sécurité', - route: '/security', - requiredPermissions: [PermissionMatrix.SYSTEM_SECURITY], - ), - ]; - - return _buildDrawer( - context, - 'Super Administrateur', - AppColors.brandGreen, - Icons.admin_panel_settings, - items, - ); - } - - /// Drawer pour Org Admin - Widget _buildOrgAdminDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.dashboard, - label: 'Control Panel', - route: '/dashboard', - requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW], - ), - const AdaptiveNavigationItem( - icon: Icons.people, - label: 'Membres', - route: '/members', - requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.account_balance_wallet, - label: 'Finances', - route: '/finances', - requiredPermissions: [PermissionMatrix.FINANCES_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.event, - label: 'Événements', - route: '/events', - requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.volunteer_activism, - label: 'Solidarité', - route: '/solidarity', - requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.assessment, - label: 'Rapports', - route: '/reports', - requiredPermissions: [PermissionMatrix.REPORTS_GENERATE], - ), - const AdaptiveNavigationItem( - icon: Icons.settings, - label: 'Configuration', - route: '/org-settings', - requiredPermissions: [PermissionMatrix.ORG_CONFIG], - ), - ]; - - return _buildDrawer( - context, - 'Administrateur', - AppColors.primaryGreen, - Icons.business_center, - items, - ); - } - - /// Drawer pour Modérateur - Widget _buildModeratorDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.dashboard, - label: 'Management Hub', - route: '/dashboard', - requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW], - ), - const AdaptiveNavigationItem( - icon: Icons.gavel, - label: 'Modération', - route: '/moderation', - requiredPermissions: [PermissionMatrix.MODERATION_CONTENT], - ), - const AdaptiveNavigationItem( - icon: Icons.people, - label: 'Membres', - route: '/members', - requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.event, - label: 'Événements', - route: '/events', - requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.message, - label: 'Communication', - route: '/communication', - requiredPermissions: [PermissionMatrix.COMM_MODERATE], - ), - ]; - - return _buildDrawer( - context, - 'Modérateur', - ColorTokens.secondaryDark, - Icons.manage_accounts, - items, - ); - } - - /// Drawer pour Membre Actif - Widget _buildActiveMemberDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.dashboard, - label: 'Activity Center', - route: '/dashboard', - requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW], - ), - const AdaptiveNavigationItem( - icon: Icons.person, - label: 'Mon Profil', - route: '/profile', - requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN], - ), - const AdaptiveNavigationItem( - icon: Icons.event, - label: 'Événements', - route: '/events', - requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.volunteer_activism, - label: 'Solidarité', - route: '/solidarity', - requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL], - ), - const AdaptiveNavigationItem( - icon: Icons.payment, - label: 'Mes Cotisations', - route: '/my-finances', - requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN], - ), - const AdaptiveNavigationItem( - icon: Icons.message, - label: 'Messages', - route: '/messages', - requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW], - ), - ]; - - return _buildDrawer( - context, - 'Membre Actif', - AppColors.brandGreenLight, - Icons.groups, - items, - ); - } - - /// Drawer pour Membre Simple - Widget _buildSimpleMemberDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.dashboard, - label: 'Mon Espace', - route: '/dashboard', - requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW], - ), - const AdaptiveNavigationItem( - icon: Icons.person, - label: 'Mon Profil', - route: '/profile', - requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN], - ), - const AdaptiveNavigationItem( - icon: Icons.event, - label: 'Événements', - route: '/events', - requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC], - ), - const AdaptiveNavigationItem( - icon: Icons.payment, - label: 'Mes Cotisations', - route: '/my-finances', - requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN], - ), - const AdaptiveNavigationItem( - icon: Icons.help, - label: 'Aide', - route: '/help', - requiredPermissions: [], - ), - ]; - - return _buildDrawer( - context, - 'Membre', - ColorTokens.secondary, - Icons.person, - items, - ); - } - - /// Drawer pour Visiteur - Widget _buildVisitorDrawer(BuildContext context) { - final items = [ - const AdaptiveNavigationItem( - icon: Icons.home, - label: 'Accueil', - route: '/dashboard', - requiredPermissions: [], - ), - const AdaptiveNavigationItem( - icon: Icons.info, - label: 'À Propos', - route: '/about', - requiredPermissions: [], - ), - const AdaptiveNavigationItem( - icon: Icons.event, - label: 'Événements Publics', - route: '/public-events', - requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC], - ), - const AdaptiveNavigationItem( - icon: Icons.contact_mail, - label: 'Contact', - route: '/contact', - requiredPermissions: [], - ), - const AdaptiveNavigationItem( - icon: Icons.login, - label: 'Se Connecter', - route: '/login', - requiredPermissions: [], - ), - ]; - - return _buildDrawer( - context, - 'Visiteur', - AppColors.brandMint, - Icons.waving_hand, - items, - ); - } - - /// Drawer basique de fallback - Widget _buildBasicDrawer(BuildContext context) { - return _buildDrawer( - context, - 'UnionFlow', - AppColors.textSecondaryLight, - Icons.dashboard, - [ - const AdaptiveNavigationItem( - icon: Icons.home, - label: 'Accueil', - route: '/dashboard', - ), - ], - ); - } - - /// Drawer de chargement - Widget _buildLoadingDrawer(BuildContext context) { - return Drawer( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.brandGreen, AppColors.primaryGreen], - ), - ), - child: const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ), - ); - } - - /// Construit un drawer avec les éléments spécifiés - Widget _buildDrawer( - BuildContext context, - String title, - Color color, - IconData icon, - List items, - ) { - return Drawer( - child: Column( - children: [ - // En-tête du drawer - _buildDrawerHeader(context, title, color, icon), - - // Éléments de navigation - Expanded( - child: ListView( - padding: EdgeInsets.zero, - children: [ - ...items.map((item) => _buildNavigationItem(context, item)), - const Divider(), - _buildLogoutItem(context), - ], - ), - ), - ], - ), - ); - } - - /// Construit l'en-tête du drawer - Widget _buildDrawerHeader( - BuildContext context, - String title, - Color color, - IconData icon, - ) { - return DrawerHeader( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [color, color.withOpacity(0.8)], - ), - ), - child: BlocBuilder( - builder: (context, state) { - if (state is AuthAuthenticated) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: Colors.white, size: 32), - const SizedBox(width: 12), - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 16), - Text( - state.user.fullName, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - ), - ), - Text( - state.user.email, - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 14, - ), - ), - ], - ); - } - - return Row( - children: [ - Icon(icon, color: Colors.white, size: 32), - const SizedBox(width: 12), - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ); - }, - ), - ); - } - - /// Construit un élément de navigation - Widget _buildNavigationItem( - BuildContext context, - AdaptiveNavigationItem item, - ) { - return SecureWidget( - requiredPermissions: item.requiredPermissions, - child: ListTile( - leading: Icon(item.icon, color: item.color), - title: Text(item.label), - trailing: item.badge != null - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - item.badge!, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ) - : null, - onTap: () { - Navigator.of(context).pop(); - onNavigate(item.route); - }, - ), - ); - } - - /// Construit l'élément de déconnexion - Widget _buildLogoutItem(BuildContext context) { - return ListTile( - leading: Icon(Icons.logout, color: AppColors.error), - title: const Text( - 'Déconnexion', - style: TextStyle(color: AppColors.error), - ), - onTap: () { - Navigator.of(context).pop(); - onLogout(); - }, - ); - } -} diff --git a/lib/core/navigation/main_navigation_layout.dart b/lib/core/navigation/main_navigation_layout.dart index 617cad2..f8e13d8 100644 --- a/lib/core/navigation/main_navigation_layout.dart +++ b/lib/core/navigation/main_navigation_layout.dart @@ -31,6 +31,10 @@ class _MainNavigationLayoutState extends State { String? _lastOrgId; Widget _getDashboardForRole(UserRole role, String userId, String? orgId) { + // Compte sans rôle métier — afficher la page d'attente sans appel API + if (role == UserRole.visitor) { + return const VisitorDashboard(); + } if (role == UserRole.orgAdmin && (orgId == null || orgId.isEmpty)) { return OrgAdminDashboardLoader(userId: userId); } @@ -83,8 +87,8 @@ class _MainNavigationLayoutState extends State { MembersPageWrapper( organisationId: role == UserRole.orgAdmin ? orgId : null, ), - const EventsPageWrapper(), - const MorePage(), + if (role != UserRole.visitor) const EventsPageWrapper(), + if (role != UserRole.visitor) const MorePage(), ]; return _cachedPages!; } @@ -150,16 +154,18 @@ class _MainNavigationLayoutState extends State { activeIcon: Icons.people_rounded, label: 'Membres', ), - const _NavItem( - icon: Icons.event_outlined, - activeIcon: Icons.event_rounded, - label: 'Événements', - ), - const _NavItem( - icon: Icons.more_horiz_rounded, - activeIcon: Icons.more_horiz_rounded, - label: 'Plus', - ), + if (role != UserRole.visitor) ...[ + const _NavItem( + icon: Icons.event_outlined, + activeIcon: Icons.event_rounded, + label: 'Événements', + ), + const _NavItem( + icon: Icons.more_horiz_rounded, + activeIcon: Icons.more_horiz_rounded, + label: 'Plus', + ), + ], ]; } } diff --git a/lib/core/navigation/more_page.dart b/lib/core/navigation/more_page.dart index 4c312ca..54e49dd 100644 --- a/lib/core/navigation/more_page.dart +++ b/lib/core/navigation/more_page.dart @@ -5,6 +5,8 @@ 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 '../di/injection_container.dart'; +import '../network/org_context_service.dart'; import '../../features/admin/presentation/pages/user_management_page.dart'; import '../../features/settings/presentation/pages/system_settings_page.dart'; @@ -12,18 +14,22 @@ 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/contributions/presentation/pages/contributions_page_wrapper.dart' show CotisationsPageWrapper; 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'; +import '../../features/organizations/presentation/pages/org_selector_page.dart'; +import '../../features/organizations/bloc/org_switcher_bloc.dart'; import '../../features/profile/presentation/pages/profile_page_wrapper.dart'; -/// Page "Plus" avec les fonctions avancées selon le rôle +/// Page "Plus" avec les fonctions avancées selon le rôle et les modules actifs. class MorePage extends StatelessWidget { const MorePage({super.key}); @override Widget build(BuildContext context) { + final orgCtx = sl(); + return BlocBuilder( builder: (context, state) { if (state is! AuthAuthenticated) { @@ -43,11 +49,12 @@ class MorePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildUserProfile(context, state), + _buildUserProfile(context, state, orgCtx), const SizedBox(height: SpacingTokens.md), - ..._buildRoleBasedOptions(context, state), + ..._buildRoleBasedOptions(context, state, orgCtx), + ..._buildModuleOptions(context, state, orgCtx), const SizedBox(height: SpacingTokens.md), - ..._buildCommonOptions(context), + ..._buildCommonOptions(context, orgCtx), ], ), ), @@ -56,7 +63,8 @@ class MorePage extends StatelessWidget { ); } - Widget _buildUserProfile(BuildContext context, AuthAuthenticated state) { + Widget _buildUserProfile( + BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) { final isDark = Theme.of(context).brightness == Brightness.dark; final textColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; final roleColor = isDark ? AppColors.brandGreenLight : AppColors.primaryGreen; @@ -90,6 +98,10 @@ class MorePage extends StatelessWidget { fontWeight: FontWeight.bold, ), ), + if (orgCtx.hasContext) ...[ + const SizedBox(height: 4), + _OrgBadge(orgCtx: orgCtx, onTap: () => _openOrgSelector(context)), + ], ], ), ), @@ -101,7 +113,20 @@ class MorePage extends StatelessWidget { ); } - List _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) { + void _openOrgSelector(BuildContext context) { + // Vérifier que OrgSwitcherBloc est disponible (fourni par un ancêtre) + try { + showOrgSelector(context); + } catch (_) { + // OrgSwitcherBloc pas fourni dans ce contexte, navigation vers ProfilePage + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfilePageWrapper()), + ); + } + } + + List _buildRoleBasedOptions( + BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) { final options = []; if (state.effectiveRole == UserRole.superAdmin) { @@ -195,7 +220,115 @@ class MorePage extends StatelessWidget { return options; } - List _buildCommonOptions(BuildContext context) { + /// Sections de modules métier — visibles uniquement si le module est actif. + List _buildModuleOptions( + BuildContext context, AuthAuthenticated state, OrgContextService orgCtx) { + final options = []; + final isAdmin = state.effectiveRole == UserRole.orgAdmin || + state.effectiveRole == UserRole.superAdmin; + + // Module TONTINE + if (orgCtx.isModuleActif('TONTINE')) { + options.add(_buildSectionTitle(context, 'Tontine')); + if (isAdmin) { + options.add(_buildOptionTile(context, + icon: Icons.autorenew, + title: 'Gestion Tontine', + subtitle: 'Cycles, cotisations et remises', + onTap: () => Navigator.pushNamed(context, '/tontine'), + )); + } else { + options.add(_buildOptionTile(context, + icon: Icons.autorenew, + title: 'Ma Tontine', + subtitle: 'Mes cycles et cotisations', + onTap: () => Navigator.pushNamed(context, '/tontine'), + )); + } + } + + // Module EPARGNE + if (orgCtx.isModuleActif('EPARGNE')) { + options.add(_buildSectionTitle(context, 'Épargne')); + options.add(_buildOptionTile(context, + icon: Icons.savings, + title: isAdmin ? 'Gestion Épargne' : 'Mon Épargne', + subtitle: isAdmin ? 'Comptes épargne et transactions' : 'Mon compte épargne', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const EpargnePage())), + )); + } + + // Module CREDIT + if (orgCtx.isModuleActif('CREDIT')) { + options.add(_buildSectionTitle(context, 'Crédit')); + options.add(_buildOptionTile(context, + icon: Icons.account_balance, + title: isAdmin ? 'Gestion Crédit' : 'Mon Crédit', + subtitle: isAdmin ? 'Demandes et suivi des crédits' : 'Mes demandes de crédit', + onTap: () => Navigator.pushNamed(context, '/credit'), + )); + } + + // Module AGRICULTURE + if (orgCtx.isModuleActif('AGRICULTURE')) { + options.add(_buildSectionTitle(context, 'Agriculture')); + options.add(_buildOptionTile(context, + icon: Icons.eco, + title: 'Campagnes Agricoles', + subtitle: 'Parcelles, récoltes et stocks', + onTap: () => Navigator.pushNamed(context, '/agricole'), + )); + } + + // Module COLLECTE_FONDS + if (orgCtx.isModuleActif('COLLECTE_FONDS')) { + options.add(_buildSectionTitle(context, 'Collecte de Fonds')); + options.add(_buildOptionTile(context, + icon: Icons.volunteer_activism, + title: 'Campagnes de Collecte', + subtitle: 'Dons et levées de fonds', + onTap: () => Navigator.pushNamed(context, '/collecte'), + )); + } + + // Module PROJETS_ONG + if (orgCtx.isModuleActif('PROJETS_ONG')) { + options.add(_buildSectionTitle(context, 'Projets ONG')); + options.add(_buildOptionTile(context, + icon: Icons.public, + title: 'Projets', + subtitle: 'Gérer et suivre les projets', + onTap: () => Navigator.pushNamed(context, '/projets-ong'), + )); + } + + // Module CULTE_DONS + if (orgCtx.isModuleActif('CULTE_DONS')) { + options.add(_buildSectionTitle(context, 'Culte & Dons')); + options.add(_buildOptionTile(context, + icon: Icons.church, + title: 'Dons et Offrandes', + subtitle: 'Gestion des dons religieux', + onTap: () => Navigator.pushNamed(context, '/culte'), + )); + } + + // Module VOTES + if (orgCtx.isModuleActif('VOTES')) { + options.add(_buildSectionTitle(context, 'Votes')); + options.add(_buildOptionTile(context, + icon: Icons.how_to_vote, + title: 'Votes & Élections', + subtitle: 'Campagnes et résultats', + onTap: () => Navigator.pushNamed(context, '/votes'), + )); + } + + return options; + } + + List _buildCommonOptions(BuildContext context, OrgContextService orgCtx) { return [ _buildSectionTitle(context, 'Général'), _buildOptionTile(context, @@ -219,17 +352,20 @@ class MorePage extends StatelessWidget { onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DemandesAidePageWrapper())), ), - _buildOptionTile(context, - icon: Icons.savings_outlined, - title: 'Comptes épargne', - subtitle: 'Mutuelle épargne – dépôts (LCB-FT)', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EpargnePage())), - ), + // Épargne — affiché en commun uniquement si le module n'est PAS actif (évite doublon avec section module) + if (!orgCtx.isModuleActif('EPARGNE')) + _buildOptionTile(context, + icon: Icons.savings_outlined, + title: 'Comptes épargne', + subtitle: 'Mutuelle épargne – dépôts (LCB-FT)', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const EpargnePage())), + ), ]; } Widget _buildSectionTitle(BuildContext context, String title) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Padding( padding: const EdgeInsets.only(top: 16, bottom: 6, left: 4), @@ -252,6 +388,7 @@ class MorePage extends StatelessWidget { required VoidCallback onTap, Color? accentColor, }) { + final isDark = Theme.of(context).brightness == Brightness.dark; final accent = accentColor ?? AppColors.primaryGreen; final titleColor = accentColor != null @@ -297,3 +434,46 @@ class MorePage extends StatelessWidget { ); } } + +/// Badge compact affichant l'organisation active avec bouton de changement. +class _OrgBadge extends StatelessWidget { + final OrgContextService orgCtx; + final VoidCallback onTap; + + const _OrgBadge({required this.orgCtx, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.business, size: 12, color: colorScheme.primary), + const SizedBox(width: 4), + Flexible( + child: Text( + orgCtx.activeOrganisationNom ?? '—', + style: TextStyle( + fontSize: 11, + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + Icon(Icons.swap_horiz, size: 12, color: colorScheme.primary), + ], + ), + ), + ); + } +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 76ed685..e4a5065 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -10,18 +10,19 @@ 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'; +import 'org_context_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() { + ApiClient(OrgContextService orgContextService) { _dio = Dio( BaseOptions( baseUrl: AppConfig.apiBaseUrl, @@ -53,6 +54,11 @@ class ApiClient { if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } + // Injecter l'organisation active si disponible + if (orgContextService.hasContext) { + options.headers[OrgContextService.headerName] = + orgContextService.activeOrganisationId; + } return handler.next(options); }, onError: (DioException e, handler) async { diff --git a/lib/core/network/org_context_service.dart b/lib/core/network/org_context_service.dart new file mode 100644 index 0000000..5f3f239 --- /dev/null +++ b/lib/core/network/org_context_service.dart @@ -0,0 +1,70 @@ +/// Service singleton qui maintient le contexte d'organisation actif. +/// +/// Injecté dans [ApiClient] pour ajouter automatiquement le header +/// [X-Active-Organisation-Id] à chaque requête backend. +library org_context_service; + +import 'package:injectable/injectable.dart'; +import '../utils/logger.dart'; + +const _kHeaderActiveOrg = 'X-Active-Organisation-Id'; + +@lazySingleton +class OrgContextService { + String? _activeOrganisationId; + String? _activeOrganisationNom; + String? _activeOrganisationType; + Set _modulesActifs = {}; + + /// L'UUID de l'organisation active (null si non sélectionnée). + String? get activeOrganisationId => _activeOrganisationId; + + /// Le nom lisible de l'organisation active. + String? get activeOrganisationNom => _activeOrganisationNom; + + /// Le type de l'organisation active (ex: ASSOCIATION, TONTINE...). + String? get activeOrganisationType => _activeOrganisationType; + + /// Modules actifs de l'organisation active (en majuscules). + Set get modulesActifs => Set.unmodifiable(_modulesActifs); + + /// Nom du header HTTP utilisé par le backend. + static const String headerName = _kHeaderActiveOrg; + + /// Indique si un contexte est disponible. + bool get hasContext => _activeOrganisationId != null; + + /// Vérifie si un module spécifique est actif. + bool isModuleActif(String module) => + _modulesActifs.contains(module.toUpperCase()); + + /// Définit l'organisation active. + void setActiveOrganisation({ + required String organisationId, + required String nom, + String? type, + String? modulesActifsCsv, + }) { + _activeOrganisationId = organisationId; + _activeOrganisationNom = nom; + _activeOrganisationType = type; + _modulesActifs = _parseModules(modulesActifsCsv); + AppLogger.info( + 'OrgContextService: organisation active → $nom ($organisationId)' + ' | modules: $_modulesActifs'); + } + + /// Réinitialise le contexte (ex: à la déconnexion). + void clear() { + _activeOrganisationId = null; + _activeOrganisationNom = null; + _activeOrganisationType = null; + _modulesActifs = {}; + AppLogger.info('OrgContextService: contexte org effacé'); + } + + Set _parseModules(String? csv) { + if (csv == null || csv.isEmpty) return {}; + return csv.split(',').map((m) => m.trim().toUpperCase()).toSet(); + } +} diff --git a/lib/core/websocket/websocket_service.dart b/lib/core/websocket/websocket_service.dart index 3988348..db26673 100644 --- a/lib/core/websocket/websocket_service.dart +++ b/lib/core/websocket/websocket_service.dart @@ -25,8 +25,10 @@ abstract class WebSocketEvent { factory WebSocketEvent.fromJson(Map json) { final eventType = json['eventType'] as String; - final timestamp = DateTime.parse(json['timestamp'] as String); - final data = json['data'] as Map; + final timestamp = json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now(); + final data = (json['data'] as Map?) ?? {}; switch (eventType) { case 'APPROVAL_PENDING': @@ -242,6 +244,26 @@ class WebSocketService { return '$baseUrl/ws/dashboard'; } + // ───────────────────────────────────────────────────────────────────────── + // Helpers de conversion de types (Flutter Web / dart2js compatibility) + // ───────────────────────────────────────────────────────────────────────── + + /// Convertit récursivement un objet JSON (potentiellement JS) en types Dart natifs. + /// Nécessaire sur Flutter Web où jsonDecode peut retourner des LegacyJavaScriptObject. + static dynamic _toDart(dynamic value) { + if (value is Map) { + return Map.fromEntries( + (value as Map).entries.map( + (e) => MapEntry(e.key.toString(), _toDart(e.value)), + ), + ); + } + if (value is List) { + return (value as List).map(_toDart).toList(); + } + return value; + } + /// Gestion des messages reçus void _onMessage(dynamic message) { try { @@ -249,12 +271,25 @@ class WebSocketService { AppLogger.debug('WebSocket message reçu: $message'); } - final json = jsonDecode(message as String) as Map; + // Sur Flutter Web (web_socket_channel ^3.0.x avec package:web), les messages + // text peuvent arriver comme JSString/LegacyJavaScriptObject plutôt que String. + // toString() fonctionne pour les strings JS primitifs. + final String rawMessage = message is String ? message : message.toString(); + if (rawMessage.isEmpty) return; + + // Convertir en types Dart natifs pour éviter les LegacyJavaScriptObject imbriqués + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is! Map) { + AppLogger.warning('WebSocket: message ignoré (non-objet): type=${decoded.runtimeType}'); + return; + } + final json = _toDart(decoded) as Map; final type = json['type'] as String?; // Gérer les messages système if (type == 'connected') { - AppLogger.info('🔗 WebSocket: ${json['data']['message']}'); + final connectedMsg = (json['data'] as Map?)?['message'] ?? 'WebSocket connecté'; + AppLogger.info('🔗 WebSocket: $connectedMsg'); return; } diff --git a/lib/features/adhesions/presentation/pages/adhesions_page.dart b/lib/features/adhesions/presentation/pages/adhesions_page.dart index 6cacd4b..8f1839e 100644 --- a/lib/features/adhesions/presentation/pages/adhesions_page.dart +++ b/lib/features/adhesions/presentation/pages/adhesions_page.dart @@ -80,7 +80,9 @@ class _AdhesionsPageState extends State action: SnackBarAction( label: 'Réessayer', textColor: Colors.white, - onPressed: () => _loadTab(_tabController.index), + onPressed: () { + if (mounted) _loadTab(_tabController.index); + }, ), ), ); diff --git a/lib/features/authentication/data/datasources/keycloak_webview_auth_service.dart b/lib/features/authentication/data/datasources/keycloak_webview_auth_service.dart index ed344ad..1c80bcb 100644 --- a/lib/features/authentication/data/datasources/keycloak_webview_auth_service.dart +++ b/lib/features/authentication/data/datasources/keycloak_webview_auth_service.dart @@ -126,10 +126,11 @@ class KeycloakWebViewAuthService { ), ); - // Clés de stockage sécurisé - static const String _accessTokenKey = 'keycloak_webview_access_token'; - static const String _idTokenKey = 'keycloak_webview_id_token'; - static const String _refreshTokenKey = 'keycloak_webview_refresh_token'; + // Clés de stockage sécurisé — alignées avec KeycloakAuthService pour éviter IC-03 + // KeycloakAuthService lit 'kc_access' / 'kc_refresh' / 'kc_id' ; ApiClient aussi. + static const String _accessTokenKey = 'kc_access'; + static const String _idTokenKey = 'kc_id'; + static const String _refreshTokenKey = 'kc_refresh'; static const String _userInfoKey = 'keycloak_webview_user_info'; static const String _authStateKey = 'keycloak_webview_auth_state'; diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index 907a2fb..9c4dc65 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -6,6 +6,7 @@ import '../../data/models/user_role.dart'; import '../../data/datasources/keycloak_auth_service.dart'; import '../../data/datasources/permission_engine.dart'; import '../../../../core/config/environment.dart'; +import '../../../../core/network/org_context_service.dart'; import '../../../../core/storage/dashboard_cache_manager.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/di/injection.dart'; @@ -87,16 +88,32 @@ class AuthPendingOnboarding extends AuthState { List get props => [onboardingState, souscriptionId, organisationId, typeOrganisation]; } +// Nouvel événement : auto-select l'org active après login (pour membres mono-org) +class AuthOrgContextInitRequested extends AuthEvent { + final String organisationId; + final String organisationNom; + final String? type; + const AuthOrgContextInitRequested({ + required this.organisationId, + required this.organisationNom, + this.type, + }); + @override + List get props => [organisationId, organisationNom, type]; +} + // === BLOC === @lazySingleton class AuthBloc extends Bloc { final KeycloakAuthService _authService; + final OrgContextService _orgContextService; - AuthBloc(this._authService) : super(AuthInitial()) { + AuthBloc(this._authService, this._orgContextService) : super(AuthInitial()) { on(_onLoginRequested); on(_onLogoutRequested); on(_onStatusChecked); on(_onTokenRefreshRequested); + on(_onOrgContextInit); } Future _onLoginRequested(AuthLoginRequested event, Emitter emit) async { @@ -185,9 +202,22 @@ class AuthBloc extends Bloc { emit(AuthLoading()); await _authService.logout(); await DashboardCacheManager.clear(); + _orgContextService.clear(); emit(AuthUnauthenticated()); } + Future _onOrgContextInit( + AuthOrgContextInitRequested event, + Emitter emit, + ) async { + _orgContextService.setActiveOrganisation( + organisationId: event.organisationId, + nom: event.organisationNom, + type: event.type, + ); + AppLogger.info('AuthBloc: contexte org initialisé → ${event.organisationNom}'); + } + Future _onStatusChecked(AuthStatusChecked event, Emitter emit) async { final tokenValid = await _authService.getValidToken(); final isAuth = tokenValid != null; @@ -276,9 +306,18 @@ class AuthBloc extends Bloc { /// /// Si le rôle est [UserRole.orgAdmin] et que [organizationContexts] est vide, /// appelle GET /api/organisations/mes pour récupérer les organisations de l'admin. + /// Auto-initialise [OrgContextService] si une seule organisation. Future _enrichUserWithOrgContext(User user) async { if (user.primaryRole != UserRole.orgAdmin || user.organizationContexts.isNotEmpty) { + // Auto-select le premier contexte existant si pas encore de contexte actif + if (!_orgContextService.hasContext && user.organizationContexts.isNotEmpty) { + final first = user.organizationContexts.first; + _orgContextService.setActiveOrganisation( + organisationId: first.organizationId, + nom: first.organizationName, + ); + } return user; } try { @@ -296,7 +335,15 @@ class AuthBloc extends Bloc { ), ) .toList(); - return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts); + if (contexts.isEmpty) return user; + // Auto-select si une seule organisation + if (contexts.length == 1 && !_orgContextService.hasContext) { + _orgContextService.setActiveOrganisation( + organisationId: contexts.first.organizationId, + nom: contexts.first.organizationName, + ); + } + return user.copyWith(organizationContexts: contexts); } catch (e) { AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e'); return user; diff --git a/lib/features/authentication/presentation/pages/keycloak_webview_auth_page.dart b/lib/features/authentication/presentation/pages/keycloak_webview_auth_page.dart index ad42e74..4406dc6 100644 --- a/lib/features/authentication/presentation/pages/keycloak_webview_auth_page.dart +++ b/lib/features/authentication/presentation/pages/keycloak_webview_auth_page.dart @@ -1,327 +1,121 @@ /// Page d'Authentification UnionFlow /// -/// Interface utilisateur pour la connexion sécurisée -/// avec gestion complète des états et des erreurs. +/// Interface utilisateur pour la connexion sécurisée via AppAuth (RFC 8252). library keycloak_webview_auth_page; -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import '../../data/datasources/keycloak_webview_auth_service.dart'; +import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:jwt_decoder/jwt_decoder.dart'; +import '../../data/datasources/keycloak_auth_service.dart'; +import '../../data/datasources/keycloak_role_mapper.dart'; import '../../data/models/user.dart'; import '../../../../shared/design_system/tokens/color_tokens.dart'; import '../../../../shared/design_system/tokens/spacing_tokens.dart'; import '../../../../shared/design_system/tokens/typography_tokens.dart'; -/// États de l'authentification WebView -enum KeycloakWebViewAuthState { - /// Initialisation en cours - initializing, - /// Chargement de la page d'authentification - loading, - /// Page d'authentification affichée - ready, - /// Authentification en cours - authenticating, - /// Authentification réussie - success, - /// Erreur d'authentification - error, - /// Timeout - timeout, -} - -/// Page d'authentification Keycloak avec WebView +/// Page d'authentification Keycloak via AppAuth class KeycloakWebViewAuthPage extends StatefulWidget { - /// Callback appelé en cas de succès d'authentification final Function(User user) onAuthSuccess; - - /// Callback appelé en cas d'erreur final Function(String error) onAuthError; - - /// Callback appelé en cas d'annulation final VoidCallback? onAuthCancel; - - /// Timeout pour l'authentification (en secondes) final int timeoutSeconds; - + const KeycloakWebViewAuthPage({ super.key, required this.onAuthSuccess, required this.onAuthError, this.onAuthCancel, - this.timeoutSeconds = 300, // 5 minutes par défaut + this.timeoutSeconds = 300, }); @override State createState() => _KeycloakWebViewAuthPageState(); } -class _KeycloakWebViewAuthPageState extends State - with TickerProviderStateMixin { - - // Contrôleurs et état - late WebViewController _webViewController; - late AnimationController _progressAnimationController; - late Animation _progressAnimation; - Timer? _timeoutTimer; - - // État de l'authentification - KeycloakWebViewAuthState _authState = KeycloakWebViewAuthState.initializing; +class _KeycloakWebViewAuthPageState extends State { + bool _loading = true; String? _errorMessage; - double _loadingProgress = 0.0; - - - // Paramètres d'authentification - String? _authUrl; + static const _appAuth = FlutterAppAuth(); + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), + ); @override void initState() { super.initState(); - _initializeAnimations(); - _initializeAuthentication(); + _authenticate(); } - @override - void dispose() { - _progressAnimationController.dispose(); - _timeoutTimer?.cancel(); - super.dispose(); - } - - /// Initialise les animations - void _initializeAnimations() { - _progressAnimationController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _progressAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _progressAnimationController, - curve: Curves.easeInOut, - )); - } - - /// Initialise l'authentification - Future _initializeAuthentication() async { + Future _authenticate() async { try { - debugPrint('🚀 Initialisation de l\'authentification WebView...'); - - setState(() { - _authState = KeycloakWebViewAuthState.initializing; - }); - - // Préparer l'authentification - final Map authParams = - await KeycloakWebViewAuthService.prepareAuthentication(); - - _authUrl = authParams['url']; - - if (_authUrl == null) { - throw Exception('URL d\'authentification manquante'); - } - - // Initialiser la WebView - await _initializeWebView(); - - // Démarrer le timer de timeout - _startTimeoutTimer(); - - debugPrint('✅ Authentification initialisée avec succès'); - - } catch (e) { - debugPrint('💥 Erreur initialisation authentification: $e'); - _handleError('Erreur d\'initialisation: $e'); - } - } - - /// Initialise la WebView - Future _initializeWebView() async { - _webViewController = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(ColorTokens.surface) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: _onLoadingProgress, - onPageStarted: _onPageStarted, - onPageFinished: _onPageFinished, - onWebResourceError: _onWebResourceError, - onNavigationRequest: _onNavigationRequest, + final result = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest( + KeycloakConfig.clientId, + 'dev.lions.unionflow-mobile://auth/callback', + serviceConfiguration: AuthorizationServiceConfiguration( + authorizationEndpoint: + '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth', + tokenEndpoint: KeycloakConfig.tokenEndpoint, + ), + scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'], + additionalParameters: {'kc_locale': 'fr'}, + allowInsecureConnections: true, ), ); - - // Charger l'URL d'authentification - if (_authUrl != null) { - await _webViewController.loadRequest(Uri.parse(_authUrl!)); - - setState(() { - _authState = KeycloakWebViewAuthState.loading; - }); - } - } - /// Démarre le timer de timeout - void _startTimeoutTimer() { - _timeoutTimer = Timer(Duration(seconds: widget.timeoutSeconds), () { - if (_authState != KeycloakWebViewAuthState.success) { - debugPrint('⏰ Timeout d\'authentification atteint'); - _handleTimeout(); - } - }); - } - - /// Gère la progression du chargement - void _onLoadingProgress(int progress) { - setState(() { - _loadingProgress = progress / 100.0; - }); - - if (progress == 100) { - _progressAnimationController.forward(); - } - } - - /// Gère le début du chargement d'une page - void _onPageStarted(String url) { - debugPrint('📄 Chargement de la page: $url'); - - setState(() { - _loadingProgress = 0.0; - }); - - _progressAnimationController.reset(); - } - - /// Gère la fin du chargement d'une page - void _onPageFinished(String url) { - debugPrint('✅ Page chargée: $url'); - - setState(() { - if (_authState == KeycloakWebViewAuthState.loading) { - _authState = KeycloakWebViewAuthState.ready; - } - }); - } - - /// Gère les erreurs de ressources web - void _onWebResourceError(WebResourceError error) { - debugPrint('💥 Erreur WebView: ${error.description}'); - - // Ignorer certaines erreurs non critiques - if (error.errorCode == -999) { // Code d'erreur pour annulation - return; - } - - _handleError('Erreur de chargement: ${error.description}'); - } - - /// Gère les requêtes de navigation - NavigationDecision _onNavigationRequest(NavigationRequest request) { - final String url = request.url; - debugPrint('🔗 Navigation vers: $url'); - - // Vérifier si c'est notre URL de callback - if (url.startsWith('dev.lions.unionflow-mobile://auth/callback')) { - debugPrint('🎯 URL de callback détectée: $url'); - _handleAuthCallback(url); - return NavigationDecision.prevent; - } - - // Vérifier d'autres patterns de callback possibles - if (url.contains('code=') && url.contains('state=')) { - debugPrint('🎯 Callback potentiel détecté (avec code et state): $url'); - _handleAuthCallback(url); - return NavigationDecision.prevent; - } - - return NavigationDecision.navigate; - } - - /// Traite le callback d'authentification - Future _handleAuthCallback(String callbackUrl) async { - try { - setState(() { - _authState = KeycloakWebViewAuthState.authenticating; - }); - - debugPrint('🔄 Traitement du callback d\'authentification...'); - debugPrint('📋 URL de callback reçue: $callbackUrl'); - - // Traiter le callback via le service - final User user = await KeycloakWebViewAuthService.handleAuthCallback(callbackUrl); - - setState(() { - _authState = KeycloakWebViewAuthState.success; - }); - - // Annuler le timer de timeout - _timeoutTimer?.cancel(); - - debugPrint('🎉 Authentification réussie pour: ${user.fullName}'); - debugPrint('👤 Rôle: ${user.primaryRole.displayName}'); - debugPrint('🔐 Permissions: ${user.additionalPermissions.length}'); - - // Notifier le succès avec un délai pour l'animation - Future.delayed(const Duration(milliseconds: 500), () { - widget.onAuthSuccess(user); - }); - - } catch (e, stackTrace) { - debugPrint('💥 Erreur traitement callback: $e'); - debugPrint('📋 Stack trace: $stackTrace'); - - // Essayer de donner plus d'informations sur l'erreur - String errorMessage = 'Erreur d\'authentification: $e'; - if (e.toString().contains('MISSING_AUTH_STATE')) { - errorMessage = 'Session expirée. Veuillez réessayer.'; - } else if (e.toString().contains('INVALID_STATE')) { - errorMessage = 'Erreur de sécurité. Veuillez réessayer.'; - } else if (e.toString().contains('MISSING_AUTH_CODE')) { - errorMessage = 'Code d\'autorisation manquant. Veuillez réessayer.'; + if (result?.accessToken == null) { + _onError('Authentification annulée ou échouée.'); + return; } - _handleError(errorMessage); + await _storage.write(key: 'kc_access', value: result!.accessToken); + if (result.refreshToken != null) { + await _storage.write(key: 'kc_refresh', value: result.refreshToken); + } + if (result.idToken != null) { + await _storage.write(key: 'kc_id', value: result.idToken); + } + + final accessPayload = JwtDecoder.decode(result.accessToken!); + final idPayload = result.idToken != null ? JwtDecoder.decode(result.idToken!) : accessPayload; + + final roles = _extractRoles(accessPayload); + final primaryRole = KeycloakRoleMapper.mapToUserRole(roles); + + final user = User( + id: idPayload['sub'] ?? '', + email: idPayload['email'] ?? '', + firstName: idPayload['given_name'] ?? '', + lastName: idPayload['family_name'] ?? '', + primaryRole: primaryRole, + additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles), + isActive: true, + lastLoginAt: DateTime.now(), + createdAt: DateTime.now(), + ); + + if (mounted) { + setState(() => _loading = false); + Future.delayed(const Duration(milliseconds: 300), () { + widget.onAuthSuccess(user); + }); + } + } catch (e) { + _onError('Erreur d\'authentification: $e'); } } - /// Gère les erreurs - void _handleError(String error) { - setState(() { - _authState = KeycloakWebViewAuthState.error; - _errorMessage = error; - }); - - _timeoutTimer?.cancel(); - - // Vibration pour indiquer l'erreur + void _onError(String error) { HapticFeedback.lightImpact(); - + if (mounted) setState(() { _loading = false; _errorMessage = error; }); widget.onAuthError(error); } - /// Gère le timeout - void _handleTimeout() { - setState(() { - _authState = KeycloakWebViewAuthState.timeout; - _errorMessage = 'Timeout d\'authentification atteint'; - }); - - HapticFeedback.lightImpact(); - - widget.onAuthError('Timeout d\'authentification'); - } - - /// Gère l'annulation void _handleCancel() { - debugPrint('❌ Authentification annulée par l\'utilisateur'); - - _timeoutTimer?.cancel(); - if (widget.onAuthCancel != null) { widget.onAuthCancel!(); } else { @@ -329,92 +123,45 @@ class _KeycloakWebViewAuthPageState extends State } } + List _extractRoles(Map payload) { + final roles = []; + if (payload['realm_access']?['roles'] != null) { + roles.addAll((payload['realm_access']['roles'] as List).cast()); + } + if (payload['resource_access'] != null) { + (payload['resource_access'] as Map).values.forEach((v) { + if (v['roles'] != null) roles.addAll((v['roles'] as List).cast()); + }); + } + return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ColorTokens.surface, - appBar: _buildAppBar(), - body: _buildBody(), - ); - } - - /// Construit l'AppBar - PreferredSizeWidget _buildAppBar() { - return AppBar( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, - elevation: 0, - title: Text( - 'Connexion Sécurisée', - style: TypographyTokens.headlineSmall.copyWith( - color: ColorTokens.onPrimary, - fontWeight: FontWeight.w600, - ), - ), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: _handleCancel, - tooltip: 'Annuler', - ), - actions: [ - if (_authState == KeycloakWebViewAuthState.ready) - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => _webViewController.reload(), - tooltip: 'Actualiser', + appBar: AppBar( + backgroundColor: ColorTokens.primary, + foregroundColor: ColorTokens.onPrimary, + elevation: 0, + title: Text( + 'Connexion Sécurisée', + style: TypographyTokens.headlineSmall.copyWith( + color: ColorTokens.onPrimary, + fontWeight: FontWeight.w600, ), - ], - bottom: _buildProgressIndicator(), + ), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _handleCancel, + tooltip: 'Annuler', + ), + ), + body: _errorMessage != null ? _buildErrorView() : _buildLoadingView(), ); } - /// Construit l'indicateur de progression - PreferredSizeWidget? _buildProgressIndicator() { - if (_authState == KeycloakWebViewAuthState.loading || - _authState == KeycloakWebViewAuthState.authenticating) { - return PreferredSize( - preferredSize: const Size.fromHeight(4.0), - child: AnimatedBuilder( - animation: _progressAnimation, - builder: (context, child) { - return LinearProgressIndicator( - value: _authState == KeycloakWebViewAuthState.authenticating - ? null - : _loadingProgress, - backgroundColor: ColorTokens.onPrimary.withOpacity(0.3), - valueColor: const AlwaysStoppedAnimation(ColorTokens.onPrimary), - ); - }, - ), - ); - } - return null; - } - - /// Construit le corps de la page - Widget _buildBody() { - switch (_authState) { - case KeycloakWebViewAuthState.initializing: - return _buildInitializingView(); - - case KeycloakWebViewAuthState.loading: - case KeycloakWebViewAuthState.ready: - return _buildWebView(); - - case KeycloakWebViewAuthState.authenticating: - return _buildAuthenticatingView(); - - case KeycloakWebViewAuthState.success: - return _buildSuccessView(); - - case KeycloakWebViewAuthState.error: - case KeycloakWebViewAuthState.timeout: - return _buildErrorView(); - } - } - - /// Vue d'initialisation - Widget _buildInitializingView() { + Widget _buildLoadingView() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -422,95 +169,14 @@ class _KeycloakWebViewAuthPageState extends State const CircularProgressIndicator(), const SizedBox(height: SpacingTokens.xl), Text( - 'Initialisation...', - style: TypographyTokens.bodyLarge.copyWith( - color: ColorTokens.onSurface, - ), + 'Connexion en cours...', + style: TypographyTokens.bodyLarge.copyWith(color: ColorTokens.onSurface), ), ], ), ); } - /// Vue WebView - Widget _buildWebView() { - return WebViewWidget(controller: _webViewController); - } - - /// Vue d'authentification en cours - Widget _buildAuthenticatingView() { - return Container( - color: ColorTokens.surface, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: SpacingTokens.xxxl), - Text( - 'Connexion en cours...', - style: TypographyTokens.headlineSmall.copyWith( - color: ColorTokens.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: SpacingTokens.xl), - Text( - 'Veuillez patienter pendant que nous\nvérifions vos informations.', - textAlign: TextAlign.center, - style: TypographyTokens.bodyMedium.copyWith( - color: ColorTokens.onSurface.withOpacity(0.7), - ), - ), - ], - ), - ), - ); - } - - /// Vue de succès - Widget _buildSuccessView() { - return Container( - color: ColorTokens.surface, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: const BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check, - color: Colors.white, - size: 48, - ), - ), - const SizedBox(height: SpacingTokens.xxxl), - Text( - 'Connexion réussie !', - style: TypographyTokens.headlineSmall.copyWith( - color: ColorTokens.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: SpacingTokens.xl), - Text( - 'Redirection vers l\'application...', - style: TypographyTokens.bodyMedium.copyWith( - color: ColorTokens.onSurface.withOpacity(0.7), - ), - ), - ], - ), - ), - ); - } - - /// Vue d'erreur Widget _buildErrorView() { return Container( color: ColorTokens.surface, @@ -526,19 +192,11 @@ class _KeycloakWebViewAuthPageState extends State color: ColorTokens.error, shape: BoxShape.circle, ), - child: Icon( - _authState == KeycloakWebViewAuthState.timeout - ? Icons.access_time - : Icons.error_outline, - color: ColorTokens.onError, - size: 48, - ), + child: const Icon(Icons.error_outline, color: ColorTokens.onError, size: 48), ), const SizedBox(height: SpacingTokens.xxxl), Text( - _authState == KeycloakWebViewAuthState.timeout - ? 'Délai d\'attente dépassé' - : 'Erreur de connexion', + 'Erreur de connexion', style: TypographyTokens.headlineSmall.copyWith( color: ColorTokens.onSurface, fontWeight: FontWeight.w600, @@ -557,7 +215,10 @@ class _KeycloakWebViewAuthPageState extends State mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton.icon( - onPressed: _initializeAuthentication, + onPressed: () { + setState(() { _loading = true; _errorMessage = null; }); + _authenticate(); + }, icon: const Icon(Icons.refresh), label: const Text('Réessayer'), style: ElevatedButton.styleFrom( diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 4067d1f..d2ce21d 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:local_auth/local_auth.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../bloc/auth_bloc.dart'; @@ -20,16 +19,11 @@ class LoginPage extends StatefulWidget { } class _LoginPageState extends State with TickerProviderStateMixin { - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - late final AnimationController _fadeController; late final AnimationController _slideController; late final Animation _fadeAnim; late final Animation _slideAnim; - bool _obscurePassword = true; - bool _rememberMe = false; bool _biometricAvailable = false; final _localAuth = LocalAuthentication(); @@ -50,15 +44,12 @@ class _LoginPageState extends State with TickerProviderStateMixin { _fadeController.forward(); _slideController.forward(); _checkBiometrics(); - _loadSavedCredentials(); } @override void dispose() { _fadeController.dispose(); _slideController.dispose(); - _emailController.dispose(); - _passwordController.dispose(); super.dispose(); } @@ -70,17 +61,6 @@ class _LoginPageState extends State with TickerProviderStateMixin { } catch (_) {} } - Future _loadSavedCredentials() async { - final prefs = await SharedPreferences.getInstance(); - final remember = prefs.getBool('uf_remember_me') ?? false; - if (remember && mounted) { - setState(() { - _rememberMe = true; - _emailController.text = prefs.getString('uf_saved_email') ?? ''; - }); - } - } - Future _authenticateBiometric() async { try { final ok = await _localAuth.authenticate( @@ -88,12 +68,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false), ); if (ok && mounted) { - final prefs = await SharedPreferences.getInstance(); - final email = prefs.getString('uf_saved_email') ?? ''; - final pass = prefs.getString('uf_saved_pass') ?? ''; - if (email.isNotEmpty && pass.isNotEmpty) { - context.read().add(AuthLoginRequested(email, pass)); - } + context.read().add(const AuthStatusChecked()); } } catch (_) {} } @@ -110,24 +85,8 @@ class _LoginPageState extends State with TickerProviderStateMixin { } catch (_) {} } - Future _onLogin() async { - final email = _emailController.text.trim(); - final password = _passwordController.text; - if (email.isEmpty || password.isEmpty) return; - - if (_rememberMe) { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('uf_remember_me', true); - await prefs.setString('uf_saved_email', email); - await prefs.setString('uf_saved_pass', password); - } else { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('uf_remember_me'); - await prefs.remove('uf_saved_email'); - await prefs.remove('uf_saved_pass'); - } - - if (mounted) context.read().add(AuthLoginRequested(email, password)); + void _onLogin() { + context.read().add(const AuthLoginRequested()); } @override @@ -267,51 +226,27 @@ class _LoginPageState extends State with TickerProviderStateMixin { ), const SizedBox(height: 12), - _GlassTextField( - controller: _emailController, - hint: 'Email ou identifiant', - icon: Icons.person_outline_rounded, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 8), - - _GlassTextField( - controller: _passwordController, - hint: 'Mot de passe', - icon: Icons.lock_outline_rounded, - isPassword: true, - obscure: _obscurePassword, - onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword), - ), - const SizedBox(height: 8), - - // Remember me + Forgot password - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _RememberMeToggle( - value: _rememberMe, - onChanged: (v) => setState(() => _rememberMe = v), + // Forgot password + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _openForgotPassword, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - TextButton( - onPressed: _openForgotPassword, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'Mot de passe oublié ?', - style: GoogleFonts.roboto( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.w600, - decoration: TextDecoration.underline, - decorationColor: Colors.white.withOpacity(0.7), - ), + child: Text( + 'Mot de passe oublié ?', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: Colors.white.withOpacity(0.7), ), ), - ], + ), ), const SizedBox(height: 16), @@ -361,108 +296,6 @@ class _LoginPageState extends State with TickerProviderStateMixin { } } -// ───────────────────────────────────────────────────────────────────────────── -// Sous-composants privés -// ───────────────────────────────────────────────────────────────────────────── - -class _GlassTextField extends StatelessWidget { - const _GlassTextField({ - required this.controller, - required this.hint, - required this.icon, - this.keyboardType, - this.isPassword = false, - this.obscure = false, - this.onToggleObscure, - }); - - final TextEditingController controller; - final String hint; - final IconData icon; - final TextInputType? keyboardType; - final bool isPassword; - final bool obscure; - final VoidCallback? onToggleObscure; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.13), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withOpacity(0.28), width: 1), - ), - child: TextField( - controller: controller, - obscureText: isPassword && obscure, - keyboardType: keyboardType, - style: GoogleFonts.roboto(fontSize: 15, color: Colors.white), - decoration: InputDecoration( - hintText: hint, - hintStyle: GoogleFonts.roboto(fontSize: 14.5, color: Colors.white.withOpacity(0.48)), - prefixIcon: Icon(icon, color: Colors.white54, size: 20), - suffixIcon: isPassword - ? IconButton( - icon: Icon( - obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, - color: Colors.white54, - size: 20, - ), - onPressed: onToggleObscure, - ) - : null, - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), - ), - ), - ); - } -} - -class _RememberMeToggle extends StatelessWidget { - const _RememberMeToggle({required this.value, required this.onChanged}); - - final bool value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onChanged(!value), - behavior: HitTestBehavior.opaque, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 18, - height: 18, - child: Checkbox( - value: value, - onChanged: (v) => onChanged(v ?? false), - fillColor: WidgetStateProperty.resolveWith((s) { - if (s.contains(WidgetState.selected)) return Colors.white; - return Colors.transparent; - }), - checkColor: const Color(0xFF2E7D32), - side: const BorderSide(color: Colors.white60, width: 1.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - const SizedBox(width: 7), - Text( - 'Se souvenir de moi', - style: GoogleFonts.roboto( - fontSize: 12, - color: Colors.white.withOpacity(0.78), - ), - ), - ], - ), - ); - } -} - // ───────────────────────────────────────────────────────────────────────────── // Painters // ───────────────────────────────────────────────────────────────────────────── diff --git a/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart b/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart index 05d49d8..df89485 100644 --- a/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart +++ b/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart @@ -50,20 +50,23 @@ class DashboardBloc extends Bloc { // Écouter les events WebSocket _webSocketEventSubscription = webSocketService.eventStream.listen( (event) { - AppLogger.info('DashboardBloc: Event WebSocket reçu - ${event.eventType}'); + try { + AppLogger.info('DashboardBloc: Event WebSocket reçu - ${event.eventType}'); - // Dispatcher uniquement les events pertinents au dashboard - if (event is DashboardStatsEvent) { - add(RefreshDashboardFromWebSocket(event.data)); - } else if (event is FinanceApprovalEvent) { - // Les approbations affectent les stats, rafraîchir - add(RefreshDashboardFromWebSocket(event.data)); - } else if (event is MemberEvent) { - // Les changements de membres affectent les stats - add(RefreshDashboardFromWebSocket(event.data)); - } else if (event is ContributionEvent) { - // Les cotisations affectent les stats financières - add(RefreshDashboardFromWebSocket(event.data)); + if (isClosed) return; + + // Dispatcher uniquement les events pertinents au dashboard + if (event is DashboardStatsEvent) { + add(RefreshDashboardFromWebSocket(event.data)); + } else if (event is FinanceApprovalEvent) { + add(RefreshDashboardFromWebSocket(event.data)); + } else if (event is MemberEvent) { + add(RefreshDashboardFromWebSocket(event.data)); + } else if (event is ContributionEvent) { + add(RefreshDashboardFromWebSocket(event.data)); + } + } catch (e, s) { + AppLogger.error('DashboardBloc: erreur lors du traitement WebSocket event', error: e); } }, onError: (error) { diff --git a/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart b/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart deleted file mode 100644 index 526250c..0000000 --- a/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart +++ /dev/null @@ -1,760 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fl_chart/fl_chart.dart'; -import '../../../../shared/design_system/unionflow_design_v2.dart'; -import '../../../../shared/design_system/unionflow_design_system.dart'; -import '../../../contributions/presentation/pages/contributions_page_wrapper.dart'; -import '../../../epargne/presentation/pages/epargne_page.dart'; -import '../../../events/presentation/pages/events_page_wrapper.dart'; -import '../bloc/dashboard_bloc.dart'; -import '../../domain/entities/dashboard_entity.dart'; - -/// Page dashboard connectée au backend - Design UnionFlow Animé -class ConnectedDashboardPage extends StatefulWidget { - final String organizationId; - final String userId; - - const ConnectedDashboardPage({ - super.key, - required this.organizationId, - required this.userId, - }); - - @override - State createState() => _ConnectedDashboardPageState(); -} - -class _ConnectedDashboardPageState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - PeriodFilter _selectedPeriod = PeriodFilter.month; - int _unreadNotifications = 5; - bool _isExporting = false; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this); - context.read().add(LoadDashboardData( - organizationId: widget.organizationId, - userId: widget.userId, - )); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.lightBackground, - appBar: _buildAppBar(), - body: AfricanPatternBackground( - child: BlocBuilder( - builder: (context, state) { - if (state is DashboardLoading) { - return const Center( - child: CircularProgressIndicator(color: AppColors.primaryGreen), - ); - } - - if (state is DashboardMemberNotRegistered) { - return _buildMemberNotRegisteredState(); - } - - if (state is DashboardError) { - return _buildErrorState(state.message); - } - - if (state is DashboardLoaded) { - return _buildDashboardContent(state); - } - - return const SizedBox.shrink(); - }, - ), - ), - ); - } - - PreferredSizeWidget _buildAppBar() { - return AppBar( - backgroundColor: AppColors.lightSurface, - elevation: 0, - title: Row( - children: [ - Hero( - tag: 'unionflow_logo', - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: const Text( - 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), - ), - ), - const SizedBox(width: 12), - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'UnionFlow', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.textPrimaryLight, - ), - ), - Text( - 'Dashboard', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ], - ), - automaticallyImplyLeading: false, - actions: [ - UnionExportButton( - isLoading: _isExporting, - onExport: (exportType) { - showDialog( - context: context, - builder: (context) => ExportConfirmDialog( - exportType: exportType, - onConfirm: () => _handleExport(exportType), - ), - ); - }, - ), - const SizedBox(width: 8), - UnionNotificationBadge( - count: _unreadNotifications, - child: IconButton( - icon: const Icon(Icons.notifications_outlined), - color: AppColors.textPrimaryLight, - onPressed: () { - setState(() => _unreadNotifications = 0); - UnionNotificationToast.show( - context, - title: 'Notifications', - message: 'Aucune nouvelle notification', - icon: Icons.notifications_active, - color: UnionFlowColors.info, - ); - }, - ), - ), - const SizedBox(width: 8), - ], - bottom: TabBar( - controller: _tabController, - labelColor: AppColors.primaryGreen, - unselectedLabelColor: AppColors.textSecondaryLight, - indicatorColor: AppColors.primaryGreen, - labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), - tabs: const [ - Tab(text: 'Vue d\'ensemble'), - Tab(text: 'Analytique'), - Tab(text: 'Activités'), - ], - ), - ); - } - - Widget _buildDashboardContent(DashboardLoaded state) { - final data = state.dashboardData; - - return RefreshIndicator( - onRefresh: () async { - context.read().add(LoadDashboardData( - organizationId: widget.organizationId, - userId: widget.userId, - )); - }, - color: AppColors.primaryGreen, - child: TabBarView( - controller: _tabController, - children: [ - _buildOverviewTab(data), - _buildAnalyticsTab(data), - _buildActivitiesTab(data), - ], - ), - ); - } - - UnionTransactionTile _activityToTile(RecentActivityEntity a) { - final amount = a.metadata != null && a.metadata!['amount'] != null - ? '${a.metadata!['amount']} FCFA' - : (a.title.isNotEmpty ? a.title : '-'); - return UnionTransactionTile( - name: a.userName, - amount: amount, - status: a.type.isNotEmpty ? a.type : 'Confirmé', - date: a.timeAgo, - ); - } - - Widget _buildOverviewTab(DashboardEntity data) { - final stats = data.stats; - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Balance principale - Animée - AnimatedSlideIn( - delay: const Duration(milliseconds: 100), - child: UnionBalanceCard( - label: 'Caisse Totale', - amount: _formatAmount(stats.totalContributionAmount), - trend: stats.monthlyGrowth > 0 ? '+${(stats.monthlyGrowth * 100).toStringAsFixed(0)}% ce mois' : 'Stable', - isTrendPositive: true, - ), - ), - const SizedBox(height: 24), - - // Stats en grille - Animées avec délai - AnimatedSlideIn( - delay: const Duration(milliseconds: 200), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Membres', - value: stats.totalMembers.toString(), - icon: Icons.people_outline, - color: AppColors.primaryGreen, - trend: '+8%', - isTrendUp: true, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Actifs', - value: stats.activeMembers.toString(), - icon: Icons.check_circle_outline, - color: UnionFlowColors.success, - trend: '+5%', - isTrendUp: true, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - AnimatedSlideIn( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: stats.totalEvents.toString(), - icon: Icons.event_outlined, - color: UnionFlowColors.gold, - trend: '+3', - isTrendUp: true, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'À venir', - value: stats.upcomingEvents.toString(), - icon: Icons.calendar_today, - color: UnionFlowColors.amber, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - - // Progression - Animée - AnimatedFadeIn( - delay: const Duration(milliseconds: 400), - child: UnionProgressCard( - title: 'Progression des Cotisations', - progress: 0.7, - subtitle: '70% des membres ont cotisé ce mois', - ), - ), - const SizedBox(height: 12), - - // Actions rapides - Animées - AnimatedSlideIn( - delay: const Duration(milliseconds: 500), - begin: const Offset(0, 0.2), - child: UnionActionGrid( - actions: [ - UnionActionButton( - icon: Icons.payment, - label: 'Cotiser', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ContributionsPageWrapper()), - ); - }, - backgroundColor: UnionFlowColors.unionGreenPale, - iconColor: AppColors.primaryGreen, - ), - UnionActionButton( - icon: Icons.send, - label: 'Envoyer', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ContributionsPageWrapper()), - ); - }, - backgroundColor: UnionFlowColors.goldPale, - iconColor: UnionFlowColors.gold, - ), - UnionActionButton( - icon: Icons.download, - label: 'Retirer', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EpargnePage()), - ); - }, - backgroundColor: UnionFlowColors.terracottaPale, - iconColor: UnionFlowColors.terracotta, - ), - UnionActionButton( - icon: Icons.add_circle_outline, - label: 'Créer', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EventsPageWrapper()), - ); - }, - backgroundColor: UnionFlowColors.infoPale, - iconColor: UnionFlowColors.info, - ), - ], - ), - ), - const SizedBox(height: 12), - - // Activité récente - Animée - AnimatedFadeIn( - delay: const Duration(milliseconds: 600), - child: UnionTransactionCard( - title: 'Activité Récente', - onSeeAll: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ContributionsPageWrapper()), - ); - }, - transactions: data.recentActivities.take(6).map((a) => _activityToTile(a)).toList(), - ), - ), - ], - ), - ); - } - - Widget _buildAnalyticsTab(DashboardEntity data) { - final stats = data.stats; - final entrees = stats.totalContributionAmount; - final sorties = stats.pendingRequests * 1000.0; - final benefice = entrees - sorties; - final taux = (stats.engagementRate * 100).toStringAsFixed(0); - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Filtre de période - Animé - AnimatedFadeIn( - delay: const Duration(milliseconds: 50), - child: UnionPeriodFilter( - selectedPeriod: _selectedPeriod, - onPeriodChanged: (period) { - setState(() => _selectedPeriod = period); - UnionNotificationToast.show( - context, - title: 'Période mise à jour', - message: 'Affichage pour ${period.label.toLowerCase()}', - icon: Icons.calendar_today, - color: AppColors.primaryGreen, - ); - }, - ), - ), - const SizedBox(height: 24), - - // Line Chart - Animé (évolution basée sur total cotisations + croissance) - AnimatedSlideIn( - delay: const Duration(milliseconds: 100), - child: UnionLineChart( - title: 'Évolution de la Caisse', - subtitle: 'Derniers 12 mois', - spots: _buildEvolutionSpots(stats.totalContributionAmount, stats.monthlyGrowth), - ), - ), - const SizedBox(height: 24), - - // Pie Chart - Animé - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), - child: UnionPieChart( - title: 'Répartition des Cotisations', - subtitle: 'Par catégorie', - sections: [ - UnionPieChartSection.create( - value: 40, - color: AppColors.primaryGreen, - title: '40%\nCotisations', - ), - UnionPieChartSection.create( - value: 30, - color: UnionFlowColors.gold, - title: '30%\nÉpargne', - ), - UnionPieChartSection.create( - value: 20, - color: UnionFlowColors.terracotta, - title: '20%\nSolidarité', - ), - UnionPieChartSection.create( - value: 10, - color: UnionFlowColors.amber, - title: '10%\nAutres', - ), - ], - ), - ), - const SizedBox(height: 12), - - // Titre - AnimatedFadeIn( - delay: const Duration(milliseconds: 400), - child: const Text( - 'Métriques Financières', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textPrimaryLight, - ), - ), - ), - const SizedBox(height: 8), - - // Métriques - Animées (données backend) - AnimatedSlideIn( - delay: const Duration(milliseconds: 500), - begin: const Offset(0, 0.2), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _buildFinanceMetric( - 'Entrées', - _formatFcfa(entrees), - Icons.arrow_downward, - UnionFlowColors.success, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildFinanceMetric( - 'Sorties', - _formatFcfa(sorties), - Icons.arrow_upward, - UnionFlowColors.error, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _buildFinanceMetric( - 'Bénéfice', - _formatFcfa(benefice), - Icons.trending_up, - UnionFlowColors.gold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildFinanceMetric( - 'Taux', - '$taux%', - Icons.percent, - UnionFlowColors.info, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildActivitiesTab(DashboardEntity data) { - final tiles = data.recentActivities.map((a) => _activityToTile(a)).toList(); - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AnimatedSlideIn( - delay: const Duration(milliseconds: 100), - child: UnionTransactionCard( - title: 'Toutes les Activités', - onSeeAll: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ContributionsPageWrapper()), - ); - }, - transactions: tiles, - ), - ), - ], - ), - ); - } - - Future _handleExport(ExportType exportType) async { - setState(() => _isExporting = true); - - // Simulation de l'export (dans un vrai cas, appel API ici) - await Future.delayed(const Duration(seconds: 2)); - - setState(() => _isExporting = false); - - if (mounted) { - UnionNotificationToast.show( - context, - title: 'Export réussi', - message: 'Le rapport ${exportType.label} a été généré avec succès', - icon: Icons.check_circle, - color: UnionFlowColors.success, - ); - } - } - - String _formatFcfa(double value) { - if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M FCFA'; - if (value >= 1000) return '${(value / 1000).toStringAsFixed(0)}K FCFA'; - return '${value.toStringAsFixed(0)} FCFA'; - } - - List _buildEvolutionSpots(double totalAmount, double monthlyGrowth) { - final spots = []; - var v = totalAmount * 0.5; - for (var i = 0; i < 12; i++) { - spots.add(FlSpot(i.toDouble(), v)); - v = v * (1 + (monthlyGrowth > 0 ? monthlyGrowth : 0.02)); - } - if (spots.isNotEmpty) spots[spots.length - 1] = FlSpot(11, totalAmount); - return spots; - } - - Widget _buildFinanceMetric(String label, String value, IconData icon, Color color) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.lightSurface, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(7), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 16, color: color), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.textSecondaryLight, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: color, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildMemberNotRegisteredState() { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.person_add_alt_1_outlined, - size: 36, - color: Colors.white, - ), - ), - const SizedBox(height: 14), - const Text( - 'Bienvenue dans UnionFlow', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimaryLight, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - const Text( - 'Votre compte est en cours de configuration par un administrateur. ' - 'Votre tableau de bord sera disponible dès que votre profil membre aura été activé.', - style: TextStyle( - fontSize: 12, - color: AppColors.textSecondaryLight, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppColors.primaryGreen.withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.primaryGreen.withOpacity(0.3)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: AppColors.primaryGreen), - SizedBox(width: 10), - Flexible( - child: Text( - 'Contactez votre administrateur si ce message persiste.', - style: TextStyle( - fontSize: 13, - color: AppColors.primaryGreen, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildErrorState(String message) { - return Center( - child: AnimatedFadeIn( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.errorPale, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.error_outline, - size: 40, - color: UnionFlowColors.error, - ), - ), - const SizedBox(height: 12), - const Text( - 'Erreur de chargement', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textPrimaryLight, - ), - ), - const SizedBox(height: 8), - Text( - message, - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondaryLight, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - UFPrimaryButton( - onPressed: () { - context.read().add(LoadDashboardData( - organizationId: widget.organizationId, - userId: widget.userId, - )); - }, - label: 'RÉESSAYER', - ), - ], - ), - ), - ); - } - - String _formatAmount(num amount) { - return '${amount.toStringAsFixed(0).replaceAllMapped( - RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]},', - )} FCFA'; - } -} diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart index b3b74e2..4d9b106 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart @@ -2,270 +2,210 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../shared/design_system/unionflow_design_v2.dart'; import '../../../../authentication/presentation/bloc/auth_bloc.dart'; -import '../../widgets/dashboard_drawer.dart'; -/// Dashboard Visiteur - Design UnionFlow Version Publique +/// Dashboard affiché pour un compte authentifié sans rôle métier actif. +/// Cas typique : nouveau membre créé par un administrateur, en attente d'activation. class VisitorDashboard extends StatelessWidget { const VisitorDashboard({super.key}); @override Widget build(BuildContext context) { + final authState = context.watch().state; + final email = authState is AuthAuthenticated ? authState.user.email : ''; + final firstName = authState is AuthAuthenticated ? authState.user.firstName : ''; + return Scaffold( backgroundColor: UnionFlowColors.background, - appBar: _buildAppBar(), - drawer: DashboardDrawer( - onNavigate: (route) { - Navigator.of(context).pushNamed(route); - }, - onLogout: () { - context.read().add(const AuthLogoutRequested()); - }, - ), - body: AfricanPatternBackground( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Message de bienvenue - AnimatedFadeIn( - delay: const Duration(milliseconds: 100), - child: _buildWelcomeCard(), - ), - const SizedBox(height: 12), + appBar: _buildAppBar(context), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 24), - // Fonctionnalités UnionFlow - AnimatedSlideIn( - delay: const Duration(milliseconds: 200), - child: const Text( - 'Découvrez UnionFlow', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), + // Icône centrale + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: UnionFlowColors.gold.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.hourglass_empty_rounded, + size: 40, + color: UnionFlowColors.gold, + ), + ), + const SizedBox(height: 20), + + // Titre + Text( + firstName.isNotEmpty ? 'Bonjour $firstName,' : 'Bienvenue,', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: UnionFlowColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Votre compte est en attente d\'activation', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: UnionFlowColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + + // Email + if (email.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: UnionFlowColors.border), ), - ), - const SizedBox(height: 8), - - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: UnionStatWidget( - label: 'Organisations', - value: '500+', - icon: Icons.business_outlined, - color: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Utilisateurs', - value: '10K+', - icon: Icons.people_outlined, - color: UnionFlowColors.gold, + const Icon(Icons.email_outlined, size: 14, color: UnionFlowColors.textSecondary), + const SizedBox(width: 6), + Text( + email, + style: const TextStyle( + fontSize: 13, + color: UnionFlowColors.textSecondary, ), ), ], ), ), - const SizedBox(height: 12), + const SizedBox(height: 28), - AnimatedFadeIn( - delay: const Duration(milliseconds: 400), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Transactions', - value: '1M+', - icon: Icons.payment_outlined, - color: UnionFlowColors.indigo, - ), + // Message explicatif + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(12), + border: const Border( + left: BorderSide(color: UnionFlowColors.gold, width: 3), + ), + boxShadow: UnionFlowColors.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Que se passe-t-il ?', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Confiance', - value: '99%', - icon: Icons.verified_outlined, - color: UnionFlowColors.success, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - - // Avantages - AnimatedSlideIn( - delay: const Duration(milliseconds: 500), - child: const Text( - 'Nos Avantages', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, ), - ), - ), - const SizedBox(height: 8), - - AnimatedFadeIn( - delay: const Duration(milliseconds: 600), - child: _buildFeature( - 'Gestion Simplifiée', - 'Gérez vos cotisations, épargnes et crédits en un seul endroit', - Icons.dashboard_customize, - UnionFlowColors.unionGreen, - ), - ), - const SizedBox(height: 12), - - AnimatedFadeIn( - delay: const Duration(milliseconds: 700), - child: _buildFeature( - 'Sécurité Optimale', - 'Vos données sont protégées avec un chiffrement de niveau bancaire', - Icons.security, - UnionFlowColors.indigo, - ), - ), - const SizedBox(height: 12), - - AnimatedFadeIn( - delay: const Duration(milliseconds: 800), - child: _buildFeature( - 'Solidarité Africaine', - 'Entraide, tontines, mutuelles et coopératives à votre portée', - Icons.favorite_outline, - UnionFlowColors.terracotta, - ), - ), - const SizedBox(height: 12), - - AnimatedFadeIn( - delay: const Duration(milliseconds: 900), - child: _buildFeature( - 'Rapports Détaillés', - 'Suivi en temps réel avec exports PDF, Excel et CSV', - Icons.analytics_outlined, - UnionFlowColors.gold, - ), - ), - const SizedBox(height: 16), - - // Call to Action - AnimatedSlideIn( - delay: const Duration(milliseconds: 1000), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(12), + const SizedBox(height: 8), + _buildStep( + '1', + 'Votre compte a été créé par l\'administrateur de votre organisation.', + UnionFlowColors.unionGreen, ), - child: Column( - children: [ - const Icon( - Icons.rocket_launch, - size: 24, - color: Colors.white, - ), - const SizedBox(height: 8), - const Text( - 'Prêt à Commencer ?', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - 'Rejoignez des milliers d\'organisations qui nous font confiance', - style: TextStyle( - fontSize: 11, - color: Colors.white.withOpacity(0.9), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - Navigator.of(context).pushNamed('/login'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: UnionFlowColors.unionGreen, - padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 0, - ), - child: const Text( - 'Créer un Compte', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - const SizedBox(height: 6), - TextButton( - onPressed: () { - Navigator.of(context).pushNamed('/login'); - }, - child: Text( - 'Déjà membre ? Se connecter', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.white.withOpacity(0.9), - ), - ), - ), - ], + const SizedBox(height: 8), + _buildStep( + '2', + 'Il doit être activé par un administrateur avant que vous puissiez accéder à la plateforme.', + UnionFlowColors.gold, ), + const SizedBox(height: 8), + _buildStep( + '3', + 'Une fois activé, reconnectez-vous pour accéder à votre espace.', + UnionFlowColors.indigo, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Bouton actualiser + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + context.read().add(const AuthStatusChecked()); + }, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text( + 'Vérifier l\'état de mon compte', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + elevation: 0, ), ), - ], - ), + ), + const SizedBox(height: 12), + + // Bouton déconnexion + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + context.read().add(const AuthLogoutRequested()); + }, + icon: const Icon(Icons.logout_rounded, size: 18), + label: const Text( + 'Se déconnecter', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + style: OutlinedButton.styleFrom( + foregroundColor: UnionFlowColors.textSecondary, + side: const BorderSide(color: UnionFlowColors.border), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + + const SizedBox(height: 32), + ], ), ), ); } - PreferredSizeWidget _buildAppBar() { + PreferredSizeWidget _buildAppBar(BuildContext context) { return AppBar( backgroundColor: UnionFlowColors.surface, elevation: 0, + automaticallyImplyLeading: false, title: Row( children: [ - Hero( - tag: 'unionflow_logo', - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: const Text( - 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: UnionFlowColors.primaryGradient, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text( + 'U', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 18, ), ), ), @@ -282,7 +222,7 @@ class VisitorDashboard extends StatelessWidget { ), ), Text( - 'Découverte', + 'Compte en attente', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w400, @@ -297,120 +237,39 @@ class VisitorDashboard extends StatelessWidget { ); } - Widget _buildWelcomeCard() { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - gradient: UnionFlowColors.subtleGradient, - borderRadius: BorderRadius.circular(10), - border: const Border( - top: BorderSide(color: UnionFlowColors.unionGreen, width: 3), - ), - boxShadow: UnionFlowColors.mediumShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(7), - decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.waving_hand, - color: Colors.white, - size: 16, - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Bienvenue sur UnionFlow', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: UnionFlowColors.textPrimary, - ), - ), - SizedBox(height: 4), - Text( - 'Votre plateforme de gestion mutualiste et associative', - style: TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - ], + Widget _buildStep(String number, String text, Color color) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, ), - const SizedBox(height: 8), - Text( - 'Gérez vos mutuelles, tontines, coopératives et associations en toute simplicité. UnionFlow est la solution complète pour la solidarité africaine.', - style: TextStyle( + alignment: Alignment.center, + child: Text( + number, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: const TextStyle( fontSize: 12, height: 1.5, - color: UnionFlowColors.textPrimary.withOpacity(0.8), + color: UnionFlowColors.textSecondary, ), ), - ], - ), - ); - } - - Widget _buildFeature(String title, String description, IconData icon, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide(color: color, width: 3), ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(7), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 15), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - description, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), + ], ); } } diff --git a/lib/features/dashboard/presentation/widgets/navigation/dashboard_navigation.dart b/lib/features/dashboard/presentation/widgets/navigation/dashboard_navigation.dart deleted file mode 100644 index 4fc71ad..0000000 --- a/lib/features/dashboard/presentation/widgets/navigation/dashboard_navigation.dart +++ /dev/null @@ -1,416 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../../../shared/design_system/unionflow_design_system.dart'; -import '../../pages/connected_dashboard_page.dart'; -import '../../pages/advanced_dashboard_page.dart'; -import '../../../../settings/presentation/pages/language_settings_page.dart'; -import '../../../../settings/presentation/pages/system_settings_page.dart'; -import '../../../../reports/presentation/pages/reports_page_wrapper.dart'; -import '../../../../members/presentation/pages/members_page_wrapper.dart'; -import '../../../../events/presentation/pages/events_page_wrapper.dart'; -import '../../../../contributions/presentation/pages/contributions_page_wrapper.dart'; - -/// Widget de navigation pour les différents types de dashboard -class DashboardNavigation extends StatefulWidget { - final String organizationId; - final String userId; - - const DashboardNavigation({ - super.key, - required this.organizationId, - required this.userId, - }); - - @override - State createState() => _DashboardNavigationState(); -} - -class _DashboardNavigationState extends State { - int _currentIndex = 0; - - final List _tabs = [ - const DashboardTab( - title: 'Accueil', - icon: Icons.home, - activeIcon: Icons.home, - type: DashboardType.home, - ), - const DashboardTab( - title: 'Analytics', - icon: Icons.analytics_outlined, - activeIcon: Icons.analytics, - type: DashboardType.analytics, - ), - const DashboardTab( - title: 'Rapports', - icon: Icons.assessment_outlined, - activeIcon: Icons.assessment, - type: DashboardType.reports, - ), - const DashboardTab( - title: 'Paramètres', - icon: Icons.settings_outlined, - activeIcon: Icons.settings, - type: DashboardType.settings, - ), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: _buildCurrentPage(), - bottomNavigationBar: _buildBottomNavigationBar(), - floatingActionButton: _buildFloatingActionButton(), - floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, - ); - } - - Widget _buildCurrentPage() { - switch (_tabs[_currentIndex].type) { - case DashboardType.home: - return ConnectedDashboardPage( - organizationId: widget.organizationId, - userId: widget.userId, - ); - case DashboardType.analytics: - return AdvancedDashboardPage( - organizationId: widget.organizationId, - userId: widget.userId, - ); - case DashboardType.reports: - return _buildReportsPage(); - case DashboardType.settings: - return _buildSettingsPage(); - } - } - - Widget _buildBottomNavigationBar() { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], - ), - child: BottomAppBar( - shape: const CircularNotchedRectangle(), - notchMargin: 8, - color: Theme.of(context).cardColor, - elevation: 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: _tabs.asMap().entries.map((entry) { - final index = entry.key; - final tab = entry.value; - final isActive = index == _currentIndex; - - // Skip the middle item for FAB space - if (index == 2) { - return const SizedBox(width: 40); - } - - return _buildNavItem(tab, isActive, index); - }).toList(), - ), - ), - ), - ); - } - - Widget _buildNavItem(DashboardTab tab, bool isActive, int index) { - return GestureDetector( - onTap: () => setState(() => _currentIndex = index), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isActive ? tab.activeIcon : tab.icon, - color: isActive ? AppColors.primaryGreen : AppColors.textSecondaryLight, - size: 20, - ), - const SizedBox(height: 4), - Text( - tab.title, - style: AppTypography.badgeText.copyWith( - color: isActive ? AppColors.primaryGreen : AppColors.textSecondaryLight, - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, - fontSize: 9, - ), - ), - ], - ), - ), - ); - } - - Widget _buildFloatingActionButton() { - return FloatingActionButton( - onPressed: _showQuickActions, - backgroundColor: AppColors.primaryGreen, - elevation: 4, - child: const Icon( - Icons.add_outlined, - color: Colors.white, - size: 28, - ), - ); - } - - Widget _buildReportsPage() { - return Scaffold( - appBar: AppBar( - title: Text('Rapports'.toUpperCase(), style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, color: Colors.white, letterSpacing: 1.1)), - backgroundColor: AppColors.primaryGreen, - foregroundColor: Colors.white, - automaticallyImplyLeading: false, - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.assessment_outlined, - size: 48, - color: AppColors.textSecondaryLight, - ), - const SizedBox(height: 16), - Text( - 'Page Rapports'.toUpperCase(), - style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'En cours de développement', - style: AppTypography.bodyTextSmall.copyWith( - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ), - ); - } - - Widget _buildSettingsPage() { - return Scaffold( - appBar: AppBar( - title: Text('Paramètres'.toUpperCase(), style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, color: Colors.white, letterSpacing: 1.1)), - backgroundColor: AppColors.primaryGreen, - foregroundColor: Colors.white, - automaticallyImplyLeading: false, - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSettingsSection( - 'Apparence', - [ - _buildSettingsTile( - 'Thème', - 'Design System UnionFlow', - Icons.palette_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage())), - ), - _buildSettingsTile( - 'Langue', - 'Français', - Icons.language_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LanguageSettingsPage())), - ), - ], - ), - const SizedBox(height: 24), - _buildSettingsSection( - 'Notifications', - [ - _buildSettingsTile( - 'Notifications push', - 'Activées', - Icons.notifications_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage())), - ), - _buildSettingsTile( - 'Emails', - 'Quotidien', - Icons.email_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage())), - ), - ], - ), - const SizedBox(height: 24), - _buildSettingsSection( - 'Données', - [ - _buildSettingsTile( - 'Synchronisation', - 'Automatique', - Icons.sync_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage())), - ), - _buildSettingsTile( - 'Cache', - 'Vider le cache', - Icons.storage_outlined, - () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage())), - ), - ], - ), - ], - ), - ); - } - - Widget _buildSettingsSection(String title, List children) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title.toUpperCase(), - style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, color: AppColors.primaryGreen, fontSize: 10), - ), - const SizedBox(height: 12), - Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.lightBorder), - ), - child: Column(children: children), - ), - ], - ); - } - - Widget _buildSettingsTile( - String title, - String subtitle, - IconData icon, - VoidCallback onTap, - ) { - return ListTile( - leading: Icon(icon, color: AppColors.primaryGreen, size: 20), - title: Text(title, style: AppTypography.actionText.copyWith(fontSize: 13)), - subtitle: Text(subtitle, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)), - trailing: const Icon( - Icons.chevron_right_outlined, - color: AppColors.textSecondaryLight, - size: 16, - ), - onTap: onTap, - ); - } - - void _showQuickActions() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.lightBorder, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 20), - Text( - 'ACTIONS RAPIDES', - style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), - ), - const SizedBox(height: 20), - GridView.count( - crossAxisCount: 3, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 12, - mainAxisSpacing: 12, - children: [ - _buildQuickActionItem(context, 'Nouveau\nMembre', Icons.person_add_outlined, AppColors.success, () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper()))), - _buildQuickActionItem(context, 'Créer\nÉvénement', Icons.event_available_outlined, AppColors.primaryGreen, () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper()))), - _buildQuickActionItem(context, 'Ajouter\nContribution', Icons.account_balance_wallet_outlined, AppColors.brandGreen, () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContributionsPageWrapper()))), - _buildQuickActionItem(context, 'Générer\nRapport', Icons.assessment_outlined, AppColors.info, () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper()))), - _buildQuickActionItem(context, 'Paramètres', Icons.settings_outlined, AppColors.textSecondaryLight, () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SystemSettingsPage()))), - ], - ), - const SizedBox(height: 20), - ], - ), - ), - ); - } - - Widget _buildQuickActionItem(BuildContext context, String title, IconData icon, Color color, VoidCallback onNavigate) { - return GestureDetector( - onTap: () { - Navigator.pop(context); - onNavigate(); - }, - child: Container( - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(12), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(height: 8), - Text( - title, - style: AppTypography.subtitleSmall.copyWith( - color: AppColors.textPrimaryLight, - fontSize: 9, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } -} - -class DashboardTab { - final String title; - final IconData icon; - final IconData activeIcon; - final DashboardType type; - - const DashboardTab({ - required this.title, - required this.icon, - required this.activeIcon, - required this.type, - }); -} - -enum DashboardType { - home, - analytics, - reports, - settings, -} diff --git a/lib/features/events/bloc/evenements_bloc.dart b/lib/features/events/bloc/evenements_bloc.dart index 5bda5dc..590cac3 100644 --- a/lib/features/events/bloc/evenements_bloc.dart +++ b/lib/features/events/bloc/evenements_bloc.dart @@ -81,12 +81,7 @@ class EvenementsBloc extends Bloc { totalPages: result.totalPages, )); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -115,12 +110,7 @@ class EvenementsBloc extends Bloc { )); } } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -151,11 +141,7 @@ class EvenementsBloc extends Bloc { code: '400', )); } else { - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; @@ -187,11 +173,7 @@ class EvenementsBloc extends Bloc { code: '400', )); } else { - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; @@ -214,12 +196,7 @@ class EvenementsBloc extends Bloc { emit(EvenementDeleted(event.id)); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -250,12 +227,7 @@ class EvenementsBloc extends Bloc { totalPages: result.totalPages, )); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -286,12 +258,7 @@ class EvenementsBloc extends Bloc { totalPages: result.totalPages, )); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -322,12 +289,7 @@ class EvenementsBloc extends Bloc { totalPages: result.totalPages, )); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -349,12 +311,7 @@ class EvenementsBloc extends Bloc { emit(EvenementInscrit(event.evenementId)); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -376,12 +333,7 @@ class EvenementsBloc extends Bloc { emit(EvenementDesinscrit(event.evenementId)); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -406,12 +358,7 @@ class EvenementsBloc extends Bloc { participants: participants, )); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -433,12 +380,7 @@ class EvenementsBloc extends Bloc { emit(EvenementsStatsLoaded(stats)); } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) return; - emit(EvenementsNetworkError( - message: _getNetworkErrorMessage(e), - code: e.response?.statusCode.toString(), - error: e, - )); + _emitDioError(e, emit); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( @@ -493,5 +435,24 @@ class EvenementsBloc extends Bloc { return 'Erreur réseau inattendue.'; } } + + /// Émet le bon état selon le type d'erreur : + /// 401/403 → EvenementsError (autorisation), autres → EvenementsNetworkError (réseau). + void _emitDioError(DioException e, Emitter emit) { + if (e.type == DioExceptionType.cancel) return; + final statusCode = e.response?.statusCode; + if (statusCode == 401 || statusCode == 403) { + emit(EvenementsError( + message: _getNetworkErrorMessage(e), + error: e, + )); + } else { + emit(EvenementsNetworkError( + message: _getNetworkErrorMessage(e), + code: statusCode?.toString(), + error: e, + )); + } + } } diff --git a/lib/features/members/bloc/membres_bloc.dart b/lib/features/members/bloc/membres_bloc.dart index 7736848..4e98abe 100644 --- a/lib/features/members/bloc/membres_bloc.dart +++ b/lib/features/members/bloc/membres_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des membres library membres_bloc; +import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; @@ -16,6 +17,8 @@ import '../domain/usecases/delete_member.dart' as uc; import '../domain/usecases/search_members.dart'; import '../domain/usecases/get_member_stats.dart'; import '../domain/repositories/membre_repository.dart'; +import '../../../core/websocket/websocket_service.dart'; +import '../../../core/utils/logger.dart'; /// BLoC pour la gestion des membres (Clean Architecture) @injectable @@ -27,7 +30,10 @@ class MembresBloc extends Bloc { final uc.DeleteMember _deleteMember; final SearchMembers _searchMembers; final GetMemberStats _getMemberStats; - final IMembreRepository _repository; // Pour méthodes non-couvertes par use cases + final IMembreRepository _repository; + final WebSocketService _webSocketService; + + StreamSubscription? _webSocketSubscription; MembresBloc( this._getMembers, @@ -38,6 +44,7 @@ class MembresBloc extends Bloc { this._searchMembers, this._getMemberStats, this._repository, + this._webSocketService, ) : super(const MembresInitial()) { on(_onLoadMembres); on(_onLoadMembreById); @@ -50,6 +57,44 @@ class MembresBloc extends Bloc { on(_onLoadActiveMembres); on(_onLoadBureauMembres); on(_onLoadMembresStats); + on(_onResetMotDePasse); + on(_onAffecterOrganisation); + on(_onInviterMembre); + on(_onActiverAdhesion); + on(_onSuspendrAdhesion); + on(_onRadierAdhesion); + + _initWebSocketListener(); + } + + void _initWebSocketListener() { + _webSocketSubscription = _webSocketService.eventStream.listen( + (event) { + try { + if (event is MemberEvent) { + AppLogger.info('MembresBloc: MemberEvent reçu (${event.eventType}), refresh liste'); + final currentState = state; + if (currentState is MembresLoaded && !isClosed) { + add(LoadMembres( + refresh: true, + organisationId: currentState.organisationId, + page: currentState.currentPage, + size: currentState.pageSize, + )); + } + } + } catch (e, s) { + AppLogger.error('MembresBloc: erreur lors du traitement WebSocket event', error: e); + } + }, + onError: (error) => AppLogger.error('MembresBloc: WebSocket stream error', error: error), + ); + } + + @override + Future close() { + _webSocketSubscription?.cancel(); + return super.close(); } /// Charge la liste des membres @@ -68,11 +113,14 @@ class MembresBloc extends Bloc { final MembreSearchResult result; if (event.organisationId != null) { - // OrgAdmin : scope la requête à son organisation via la recherche avancée + // OrgAdmin : scope la requête à son organisation via la recherche avancée. + // includeInactifs=true pour récupérer aussi les membres "En attente" + // (actif=false) — le filtrage par statut est géré côté UI. result = await _searchMembers( criteria: MembreSearchCriteria( organisationIds: [event.organisationId!], query: event.recherche?.isNotEmpty == true ? event.recherche : null, + includeInactifs: true, ), page: event.page, size: event.size, @@ -298,6 +346,60 @@ class MembresBloc extends Bloc { } } + /// Réinitialise le mot de passe d'un membre + Future _onResetMotDePasse( + ResetMotDePasse event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + + final membre = await _repository.resetMotDePasse(event.id); + + emit(MotDePasseReinitialise(membre)); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(MembresError( + message: 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.', + error: e, + )); + } + } + + /// Affecte un membre à une organisation (superadmin) + Future _onAffecterOrganisation( + AffecterOrganisation event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + + final membre = await _repository.affecterOrganisation(event.membreId, event.organisationId); + + emit(MembreAffecte(membre)); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(MembresError( + message: 'Erreur lors de l\'affectation à l\'organisation. Veuillez réessayer.', + error: e, + )); + } + } + /// Recherche avancée de membres Future _onSearchMembres( SearchMembres event, @@ -479,5 +581,116 @@ class MembresBloc extends Bloc { return 'Erreur réseau inattendue.'; } } + + // ── Handlers cycle de vie des adhésions ────────────────────────────────── + + Future _onInviterMembre( + InviterMembre event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + final result = await _repository.inviterMembre( + event.membreId, + event.organisationId, + roleOrg: event.roleOrg, + ); + emit(MembreInvite( + membreId: event.membreId, + organisationId: event.organisationId, + nouveauStatut: result['statut']?.toString() ?? 'INVITE', + )); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + emit(MembresError(message: 'Erreur lors de l\'invitation du membre.', error: e)); + } + } + + Future _onActiverAdhesion( + ActiverAdhesion event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + final result = await _repository.activerAdhesion( + event.membreId, + event.organisationId, + motif: event.motif, + ); + emit(AdhesionActivee( + membreId: event.membreId, + nouveauStatut: result['statut']?.toString() ?? 'ACTIF', + )); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + emit(MembresError(message: 'Erreur lors de l\'activation de l\'adhésion.', error: e)); + } + } + + Future _onSuspendrAdhesion( + SuspendrAdhesion event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + final result = await _repository.suspendrAdhesion( + event.membreId, + event.organisationId, + motif: event.motif, + ); + emit(AdhesionSuspendue( + membreId: event.membreId, + nouveauStatut: result['statut']?.toString() ?? 'SUSPENDU', + )); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + emit(MembresError(message: 'Erreur lors de la suspension de l\'adhésion.', error: e)); + } + } + + Future _onRadierAdhesion( + RadierAdhesion event, + Emitter emit, + ) async { + try { + emit(const MembresLoading()); + final result = await _repository.radierAdhesion( + event.membreId, + event.organisationId, + motif: event.motif, + ); + emit(MembreRadie( + membreId: event.membreId, + nouveauStatut: result['statut']?.toString() ?? 'RADIE', + )); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(MembresNetworkError( + message: _getNetworkErrorMessage(e), + code: e.response?.statusCode.toString(), + error: e, + )); + } catch (e) { + emit(MembresError(message: 'Erreur lors de la radiation du membre.', error: e)); + } + } } diff --git a/lib/features/members/bloc/membres_event.dart b/lib/features/members/bloc/membres_event.dart index a154782..40f2c89 100644 --- a/lib/features/members/bloc/membres_event.dart +++ b/lib/features/members/bloc/membres_event.dart @@ -143,3 +143,90 @@ class LoadMembresStats extends MembresEvent { const LoadMembresStats(); } +/// Événement pour réinitialiser le mot de passe d'un membre existant +class ResetMotDePasse extends MembresEvent { + final String id; + + const ResetMotDePasse(this.id); + + @override + List get props => [id]; +} + +/// Événement pour affecter un membre à une organisation (superadmin) +class AffecterOrganisation extends MembresEvent { + final String membreId; + final String organisationId; + + const AffecterOrganisation(this.membreId, this.organisationId); + + @override + List get props => [membreId, organisationId]; +} + +// ── Cycle de vie des adhésions ──────────────────────────────────────────── + +/// Inviter un membre dans une organisation (admin) +class InviterMembre extends MembresEvent { + final String membreId; + final String organisationId; + final String? roleOrg; + + const InviterMembre({ + required this.membreId, + required this.organisationId, + this.roleOrg, + }); + + @override + List get props => [membreId, organisationId, roleOrg]; +} + +/// Activer l'adhésion d'un membre dans l'organisation courante +class ActiverAdhesion extends MembresEvent { + final String membreId; + final String organisationId; + final String? motif; + + const ActiverAdhesion({ + required this.membreId, + required this.organisationId, + this.motif, + }); + + @override + List get props => [membreId, organisationId, motif]; +} + +/// Suspendre l'adhésion d'un membre +class SuspendrAdhesion extends MembresEvent { + final String membreId; + final String organisationId; + final String? motif; + + const SuspendrAdhesion({ + required this.membreId, + required this.organisationId, + this.motif, + }); + + @override + List get props => [membreId, organisationId, motif]; +} + +/// Radier un membre d'une organisation +class RadierAdhesion extends MembresEvent { + final String membreId; + final String organisationId; + final String? motif; + + const RadierAdhesion({ + required this.membreId, + required this.organisationId, + this.motif, + }); + + @override + List get props => [membreId, organisationId, motif]; +} + diff --git a/lib/features/members/bloc/membres_state.dart b/lib/features/members/bloc/membres_state.dart index 677e854..4cd97b5 100644 --- a/lib/features/members/bloc/membres_state.dart +++ b/lib/features/members/bloc/membres_state.dart @@ -141,6 +141,78 @@ class MembreDeactivated extends MembresState { List get props => [membre]; } +/// État de succès après affectation à une organisation +class MembreAffecte extends MembresState { + final MembreCompletModel membre; + + const MembreAffecte(this.membre); + + @override + List get props => [membre]; +} + +/// État de succès après réinitialisation du mot de passe +/// [membre] contient motDePasseTemporaire renseigné (retourné une seule fois) +class MotDePasseReinitialise extends MembresState { + final MembreCompletModel membre; + + const MotDePasseReinitialise(this.membre); + + @override + List get props => [membre]; +} + +// ── États cycle de vie adhésion ────────────────────────────────────────── + +/// Invitation envoyée avec succès +class MembreInvite extends MembresState { + final String membreId; + final String organisationId; + final String nouveauStatut; + + const MembreInvite({ + required this.membreId, + required this.organisationId, + required this.nouveauStatut, + }); + + @override + List get props => [membreId, organisationId, nouveauStatut]; +} + +/// Adhésion activée +class AdhesionActivee extends MembresState { + final String membreId; + final String nouveauStatut; + + const AdhesionActivee({required this.membreId, required this.nouveauStatut}); + + @override + List get props => [membreId, nouveauStatut]; +} + +/// Adhésion suspendue +class AdhesionSuspendue extends MembresState { + final String membreId; + final String nouveauStatut; + + const AdhesionSuspendue({required this.membreId, required this.nouveauStatut}); + + @override + List get props => [membreId, nouveauStatut]; +} + +/// Membre radié +class MembreRadie extends MembresState { + final String membreId; + final String nouveauStatut; + + const MembreRadie({required this.membreId, required this.nouveauStatut}); + + @override + List get props => [membreId, nouveauStatut]; +} + /// État avec statistiques class MembresStatsLoaded extends MembresState { final Map stats; diff --git a/lib/features/members/data/models/membre_complete_model.dart b/lib/features/members/data/models/membre_complete_model.dart index 83f1e73..21cda2d 100644 --- a/lib/features/members/data/models/membre_complete_model.dart +++ b/lib/features/members/data/models/membre_complete_model.dart @@ -25,7 +25,7 @@ enum StatutMembre { inactif, @JsonValue('SUSPENDU') suspendu, - @JsonValue('EN_ATTENTE') + @JsonValue('EN_ATTENTE_VALIDATION') enAttente, } @@ -67,6 +67,10 @@ class MembreCompletModel extends Equatable { /// Téléphone final String? telephone; + /// Téléphone Wave (mobile money) + @JsonKey(name: 'telephoneWave') + final String? telephoneWave; + /// Date de naissance @JsonKey(name: 'dateNaissance') final DateTime? dateNaissance; @@ -100,6 +104,7 @@ class MembreCompletModel extends Equatable { final String? photo; /// Statut du membre + @JsonKey(name: 'statutCompte') final StatutMembre statut; /// Rôle dans l'organisation @@ -148,6 +153,18 @@ class MembreCompletModel extends Equatable { @JsonKey(name: 'derniereActivite') final DateTime? derniereActivite; + /// Statut matrimonial (CELIBATAIRE, MARIE, DIVORCE, VEUF) + @JsonKey(name: 'statutMatrimonial') + final String? statutMatrimonial; + + /// Type de pièce d'identité (CNI, PASSEPORT, PERMIS_CONDUIRE, TITRE_SEJOUR) + @JsonKey(name: 'typeIdentite') + final String? typeIdentite; + + /// Numéro de pièce d'identité + @JsonKey(name: 'numeroIdentite') + final String? numeroIdentite; + /// Notes internes final String? notes; @@ -184,6 +201,7 @@ class MembreCompletModel extends Equatable { required this.prenom, required this.email, this.telephone, + this.telephoneWave, this.dateNaissance, this.genre, this.adresse, @@ -207,6 +225,9 @@ class MembreCompletModel extends Equatable { this.cotisationAJour = false, this.nombreEvenementsParticipes = 0, this.derniereActivite, + this.statutMatrimonial, + this.typeIdentite, + this.numeroIdentite, this.notes, this.dateCreation, this.dateModification, @@ -231,6 +252,7 @@ class MembreCompletModel extends Equatable { String? prenom, String? email, String? telephone, + String? telephoneWave, DateTime? dateNaissance, Genre? genre, String? adresse, @@ -254,6 +276,9 @@ class MembreCompletModel extends Equatable { bool? cotisationAJour, int? nombreEvenementsParticipes, DateTime? derniereActivite, + String? statutMatrimonial, + String? typeIdentite, + String? numeroIdentite, String? notes, DateTime? dateCreation, DateTime? dateModification, @@ -269,6 +294,7 @@ class MembreCompletModel extends Equatable { prenom: prenom ?? this.prenom, email: email ?? this.email, telephone: telephone ?? this.telephone, + telephoneWave: telephoneWave ?? this.telephoneWave, dateNaissance: dateNaissance ?? this.dateNaissance, genre: genre ?? this.genre, adresse: adresse ?? this.adresse, @@ -292,6 +318,9 @@ class MembreCompletModel extends Equatable { cotisationAJour: cotisationAJour ?? this.cotisationAJour, nombreEvenementsParticipes: nombreEvenementsParticipes ?? this.nombreEvenementsParticipes, derniereActivite: derniereActivite ?? this.derniereActivite, + statutMatrimonial: statutMatrimonial ?? this.statutMatrimonial, + typeIdentite: typeIdentite ?? this.typeIdentite, + numeroIdentite: numeroIdentite ?? this.numeroIdentite, notes: notes ?? this.notes, dateCreation: dateCreation ?? this.dateCreation, dateModification: dateModification ?? this.dateModification, @@ -341,6 +370,7 @@ class MembreCompletModel extends Equatable { prenom, email, telephone, + telephoneWave, dateNaissance, genre, adresse, @@ -364,6 +394,9 @@ class MembreCompletModel extends Equatable { cotisationAJour, nombreEvenementsParticipes, derniereActivite, + statutMatrimonial, + typeIdentite, + numeroIdentite, notes, dateCreation, dateModification, diff --git a/lib/features/members/data/models/membre_complete_model.g.dart b/lib/features/members/data/models/membre_complete_model.g.dart index fa7dd8d..ebf25d7 100644 --- a/lib/features/members/data/models/membre_complete_model.g.dart +++ b/lib/features/members/data/models/membre_complete_model.g.dart @@ -13,6 +13,7 @@ MembreCompletModel _$MembreCompletModelFromJson(Map json) => prenom: json['prenom'] as String, email: json['email'] as String, telephone: json['telephone'] as String?, + telephoneWave: json['telephoneWave'] as String?, dateNaissance: json['dateNaissance'] == null ? null : DateTime.parse(json['dateNaissance'] as String), @@ -25,7 +26,7 @@ MembreCompletModel _$MembreCompletModelFromJson(Map json) => profession: json['profession'] as String?, nationalite: json['nationalite'] as String?, photo: json['photo'] as String?, - statut: $enumDecodeNullable(_$StatutMembreEnumMap, json['statut']) ?? + statut: $enumDecodeNullable(_$StatutMembreEnumMap, json['statutCompte']) ?? StatutMembre.actif, role: json['role'] as String?, organisationId: json['organisationId'] as String?, @@ -46,6 +47,9 @@ MembreCompletModel _$MembreCompletModelFromJson(Map json) => derniereActivite: json['derniereActivite'] == null ? null : DateTime.parse(json['derniereActivite'] as String), + statutMatrimonial: json['statutMatrimonial'] as String?, + typeIdentite: json['typeIdentite'] as String?, + numeroIdentite: json['numeroIdentite'] as String?, notes: json['notes'] as String?, dateCreation: json['dateCreation'] == null ? null @@ -70,6 +74,7 @@ Map _$MembreCompletModelToJson(MembreCompletModel instance) => 'prenom': instance.prenom, 'email': instance.email, 'telephone': instance.telephone, + 'telephoneWave': instance.telephoneWave, 'dateNaissance': instance.dateNaissance?.toIso8601String(), 'genre': _$GenreEnumMap[instance.genre], 'adresse': instance.adresse, @@ -80,7 +85,7 @@ Map _$MembreCompletModelToJson(MembreCompletModel instance) => 'profession': instance.profession, 'nationalite': instance.nationalite, 'photo': instance.photo, - 'statut': _$StatutMembreEnumMap[instance.statut]!, + 'statutCompte': _$StatutMembreEnumMap[instance.statut]!, 'role': instance.role, 'organisationId': instance.organisationId, 'organisationNom': instance.organisationNom, @@ -93,6 +98,9 @@ Map _$MembreCompletModelToJson(MembreCompletModel instance) => 'cotisationAJour': instance.cotisationAJour, 'nombreEvenementsParticipes': instance.nombreEvenementsParticipes, 'derniereActivite': instance.derniereActivite?.toIso8601String(), + 'statutMatrimonial': instance.statutMatrimonial, + 'typeIdentite': instance.typeIdentite, + 'numeroIdentite': instance.numeroIdentite, 'notes': instance.notes, 'dateCreation': instance.dateCreation?.toIso8601String(), 'dateModification': instance.dateModification?.toIso8601String(), @@ -115,7 +123,7 @@ const _$StatutMembreEnumMap = { StatutMembre.actif: 'ACTIF', StatutMembre.inactif: 'INACTIF', StatutMembre.suspendu: 'SUSPENDU', - StatutMembre.enAttente: 'EN_ATTENTE', + StatutMembre.enAttente: 'EN_ATTENTE_VALIDATION', }; const _$NiveauVigilanceKycEnumMap = { diff --git a/lib/features/members/data/repositories/membre_repository_impl.dart b/lib/features/members/data/repositories/membre_repository_impl.dart index 5a7d867..48eb3c8 100644 --- a/lib/features/members/data/repositories/membre_repository_impl.dart +++ b/lib/features/members/data/repositories/membre_repository_impl.dart @@ -240,7 +240,7 @@ class MembreRepositoryImpl implements IMembreRepository { @override Future activateMembre(String id) async { try { - final response = await _apiClient.post('$_baseUrl/$id/activer'); + final response = await _apiClient.put('$_baseUrl/$id/activer'); if (response.statusCode == 200) { return MembreCompletModel.fromJson(response.data as Map); @@ -257,7 +257,7 @@ class MembreRepositoryImpl implements IMembreRepository { @override Future deactivateMembre(String id) async { try { - final response = await _apiClient.post('$_baseUrl/$id/desactiver'); + final response = await _apiClient.put('$_baseUrl/$id/desactiver'); if (response.statusCode == 200) { return MembreCompletModel.fromJson(response.data as Map); @@ -339,5 +339,114 @@ class MembreRepositoryImpl implements IMembreRepository { rethrow; } } + + @override + Future resetMotDePasse(String id) async { + try { + final response = await _apiClient.put('$_baseUrl/$id/reinitialiser-mot-de-passe'); + + if (response.statusCode == 200) { + return MembreCompletModel.fromJson(response.data as Map); + } else { + throw Exception('Erreur lors de la réinitialisation du mot de passe: ${response.statusCode}'); + } + } on DioException { + rethrow; + } catch (e) { + rethrow; + } + } + + @override + Future affecterOrganisation(String membreId, String organisationId) async { + try { + final response = await _apiClient.put( + '$_baseUrl/$membreId/affecter-organisation', + queryParameters: {'organisationId': organisationId}, + ); + + if (response.statusCode == 200) { + return MembreCompletModel.fromJson(response.data as Map); + } else { + throw Exception('Erreur lors de l\'affectation à l\'organisation: ${response.statusCode}'); + } + } on DioException { + rethrow; + } catch (e) { + rethrow; + } + } + + // ── Cycle de vie des adhésions ──────────────────────────────────────────── + + @override + Future> inviterMembre( + String membreId, + String organisationId, { + String? roleOrg, + }) async { + final response = await _apiClient.put( + '$_baseUrl/$membreId/inviter-organisation', + queryParameters: { + 'organisationId': organisationId, + if (roleOrg != null) 'roleOrg': roleOrg, + }, + ); + if (response.statusCode == 200) { + return Map.from(response.data as Map); + } + throw Exception('Invitation échouée: ${response.statusCode}'); + } + + @override + Future> activerAdhesion( + String membreId, + String organisationId, { + String? motif, + }) async { + final response = await _apiClient.put( + '$_baseUrl/$membreId/adhesion/activer', + queryParameters: {'organisationId': organisationId}, + data: motif != null ? {'motif': motif} : {}, + ); + if (response.statusCode == 200) { + return Map.from(response.data as Map); + } + throw Exception('Activation échouée: ${response.statusCode}'); + } + + @override + Future> suspendrAdhesion( + String membreId, + String organisationId, { + String? motif, + }) async { + final response = await _apiClient.put( + '$_baseUrl/$membreId/adhesion/suspendre', + queryParameters: {'organisationId': organisationId}, + data: motif != null ? {'motif': motif} : {}, + ); + if (response.statusCode == 200) { + return Map.from(response.data as Map); + } + throw Exception('Suspension échouée: ${response.statusCode}'); + } + + @override + Future> radierAdhesion( + String membreId, + String organisationId, { + String? motif, + }) async { + final response = await _apiClient.put( + '$_baseUrl/$membreId/adhesion/radier', + queryParameters: {'organisationId': organisationId}, + data: motif != null ? {'motif': motif} : {}, + ); + if (response.statusCode == 200) { + return Map.from(response.data as Map); + } + throw Exception('Radiation échouée: ${response.statusCode}'); + } } diff --git a/lib/features/members/domain/repositories/membre_repository.dart b/lib/features/members/domain/repositories/membre_repository.dart index 29a4278..8812b6b 100644 --- a/lib/features/members/domain/repositories/membre_repository.dart +++ b/lib/features/members/domain/repositories/membre_repository.dart @@ -48,4 +48,24 @@ abstract class IMembreRepository { /// Récupère les statistiques des membres Future> getMembresStats(); + + /// Réinitialise le mot de passe d'un membre — retourne le membre avec motDePasseTemporaire + Future resetMotDePasse(String id); + + /// Affecte un membre à une organisation (superadmin uniquement) + Future affecterOrganisation(String membreId, String organisationId); + + // ── Cycle de vie des adhésions ─────────────────────────────────────────── + + /// Invite un membre dans une organisation (statut INVITE, token 7j) + Future> inviterMembre(String membreId, String organisationId, {String? roleOrg}); + + /// Active l'adhésion d'un membre (EN_ATTENTE/INVITE/SUSPENDU → ACTIF) + Future> activerAdhesion(String membreId, String organisationId, {String? motif}); + + /// Suspend l'adhésion d'un membre (ACTIF → SUSPENDU) + Future> suspendrAdhesion(String membreId, String organisationId, {String? motif}); + + /// Radie un membre d'une organisation + Future> radierAdhesion(String membreId, String organisationId, {String? motif}); } diff --git a/lib/features/members/presentation/pages/members_page.dart b/lib/features/members/presentation/pages/members_page.dart deleted file mode 100644 index b9c74a6..0000000 --- a/lib/features/members/presentation/pages/members_page.dart +++ /dev/null @@ -1,1981 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../shared/design_system/tokens/app_colors.dart'; -import '../../../../shared/design_system/unionflow_design_system.dart'; -import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; -import '../../../../features/authentication/data/models/user_role.dart'; -import '../../../adhesions/presentation/pages/adhesions_page_wrapper.dart'; - -/// Page de gestion des membres - Interface sophistiquée et exhaustive -/// -/// Cette page offre une interface complète pour la gestion des membres -/// avec des fonctionnalités avancées de recherche, filtrage, statistiques -/// et actions de gestion basées sur les permissions utilisateur. -class MembersPage extends StatefulWidget { - const MembersPage({super.key}); - - @override - State createState() => _MembersPageState(); -} - -class _MembersPageState extends State with TickerProviderStateMixin { - // Controllers et état - final TextEditingController _searchController = TextEditingController(); - late TabController _tabController; - - // État de l'interface - String _searchQuery = ''; - String _selectedFilter = 'Tous'; - - bool _isGridView = false; - bool _showAdvancedFilters = false; - - // Filtres avancés - final List _selectedRoles = []; - - // Données de démonstration enrichies - final List> _allMembers = [ - { - 'id': '1', - 'name': 'Marie Dubois', - 'email': 'marie.dubois@unionflow.com', - 'role': 'Membre Actif', - 'status': 'Actif', - 'joinDate': DateTime(2023, 1, 15), - 'lastActivity': DateTime(2024, 9, 19), - 'avatar': null, - 'phone': '+33 6 12 34 56 78', - 'department': 'Ressources Humaines', - 'location': 'Paris, France', - 'permissions': 15, - 'contributionScore': 85, - 'eventsAttended': 12, - 'projectsInvolved': 5, - }, - { - 'id': '2', - 'name': 'Pierre Martin', - 'email': 'pierre.martin@unionflow.com', - 'role': 'Modérateur', - 'status': 'Actif', - 'joinDate': DateTime(2022, 11, 20), - 'lastActivity': DateTime(2024, 9, 20), - 'avatar': null, - 'phone': '+33 6 98 76 54 32', - 'department': 'IT & Développement', - 'location': 'Lyon, France', - 'permissions': 25, - 'contributionScore': 92, - 'eventsAttended': 18, - 'projectsInvolved': 8, - }, - { - 'id': '3', - 'name': 'Sophie Laurent', - 'email': 'sophie.laurent@unionflow.com', - 'role': 'Membre Simple', - 'status': 'Inactif', - 'joinDate': DateTime(2024, 2, 10), - 'lastActivity': DateTime(2024, 8, 15), - 'avatar': null, - 'phone': '+33 6 45 67 89 01', - 'department': 'Marketing', - 'location': 'Marseille, France', - 'permissions': 8, - 'contributionScore': 45, - 'eventsAttended': 3, - 'projectsInvolved': 1, - }, - { - 'id': '4', - 'name': 'Thomas Durand', - 'email': 'thomas.durand@unionflow.com', - 'role': 'Administrateur Org', - 'status': 'Actif', - 'joinDate': DateTime(2021, 6, 5), - 'lastActivity': DateTime(2024, 9, 20), - 'avatar': null, - 'phone': '+33 6 23 45 67 89', - 'department': 'Administration', - 'location': 'Toulouse, France', - 'permissions': 35, - 'contributionScore': 98, - 'eventsAttended': 25, - 'projectsInvolved': 12, - }, - { - 'id': '5', - 'name': 'Emma Rousseau', - 'email': 'emma.rousseau@unionflow.com', - 'role': 'Gestionnaire RH', - 'status': 'Actif', - 'joinDate': DateTime(2023, 3, 12), - 'lastActivity': DateTime(2024, 9, 19), - 'avatar': null, - 'phone': '+33 6 34 56 78 90', - 'department': 'Ressources Humaines', - 'location': 'Nantes, France', - 'permissions': 28, - 'contributionScore': 88, - 'eventsAttended': 15, - 'projectsInvolved': 7, - }, - { - 'id': '6', - 'name': 'Lucas Bernard', - 'email': 'lucas.bernard@unionflow.com', - 'role': 'Consultant', - 'status': 'En attente', - 'joinDate': DateTime(2024, 9, 1), - 'lastActivity': DateTime(2024, 9, 18), - 'avatar': null, - 'phone': '+33 6 56 78 90 12', - 'department': 'Consulting', - 'location': 'Bordeaux, France', - 'permissions': 12, - 'contributionScore': 0, - 'eventsAttended': 0, - 'projectsInvolved': 0, - }, - { - 'id': '7', - 'name': 'Camille Moreau', - 'email': 'camille.moreau@unionflow.com', - 'role': 'Membre Actif', - 'status': 'Suspendu', - 'joinDate': DateTime(2022, 8, 30), - 'lastActivity': DateTime(2024, 7, 10), - 'avatar': null, - 'phone': '+33 6 67 89 01 23', - 'department': 'Ventes', - 'location': 'Lille, France', - 'permissions': 15, - 'contributionScore': 65, - 'eventsAttended': 8, - 'projectsInvolved': 3, - }, - ]; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 4, vsync: this); - } - - @override - void dispose() { - _searchController.dispose(); - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is! AuthAuthenticated) { - return Container( - color: AppColors.lightBackground, - child: const Center(child: CircularProgressIndicator()), - ); - } - - return Container( - color: AppColors.lightBackground, - child: _buildMembersContent(state), - ); - }, - ); - } - - /// Contenu principal de la page membres - Widget _buildMembersContent(AuthAuthenticated state) { - return SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header avec titre et actions - _buildMembersHeader(state), - const SizedBox(height: 8), - - // Statistiques et métriques - _buildMembersMetrics(), - const SizedBox(height: 8), - - // Barre de recherche et filtres - _buildSearchAndFilters(), - const SizedBox(height: 8), - - // Onglets de catégories - _buildCategoryTabs(), - const SizedBox(height: 8), - - // Liste/Grille des membres - _buildMembersDisplay(), - ], - ), - ); - } - - /// Header avec titre et actions principales - Widget _buildMembersHeader(AuthAuthenticated state) { - final canManageMembers = _canManageMembers(state.effectiveRole); - - return UFPageHeader( - title: 'Membres', - icon: Icons.people, - iconColor: ColorTokens.primary, - actions: canManageMembers - ? [ - IconButton( - icon: const Icon(Icons.checklist), - onPressed: () => _showBulkActions(), - tooltip: 'Actions groupées', - ), - IconButton( - icon: const Icon(Icons.download), - onPressed: () => _exportMembers(), - tooltip: 'Exporter', - ), - IconButton( - icon: const Icon(Icons.person_add), - onPressed: () => _showAddMemberDialog(), - tooltip: 'Ajouter un membre', - ), - ] - : null, - ); - } - - /// Section des métriques et statistiques - Widget _buildMembersMetrics() { - final totalMembers = _allMembers.length; - final activeMembers = _allMembers.where((m) => m['status'] == 'Actif').length; - final newThisMonth = _allMembers.where((m) { - final joinDate = m['joinDate'] as DateTime; - final now = DateTime.now(); - return joinDate.year == now.year && joinDate.month == now.month; - }).length; - final avgContribution = _allMembers.map((m) => m['contributionScore'] as int).reduce((a, b) => a + b) / totalMembers; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Métriques & Statistiques', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppColors.primaryGreen, - fontSize: 14, - ), - ), - const SizedBox(height: 12), - - // Première ligne de métriques - Row( - children: [ - Expanded( - child: _buildMetricCard( - 'Total Membres', - totalMembers.toString(), - '+$newThisMonth ce mois', - Icons.people, - AppColors.primaryGreen, - trend: newThisMonth > 0 ? 'up' : 'stable', - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildMetricCard( - 'Membres Actifs', - activeMembers.toString(), - '${((activeMembers / totalMembers) * 100).toStringAsFixed(1)}%', - Icons.check_circle, - AppColors.success, - trend: 'up', - ), - ), - ], - ), - const SizedBox(height: 8), - - // Deuxième ligne de métriques - Row( - children: [ - Expanded( - child: _buildMetricCard( - 'Score Moyen', - avgContribution.toStringAsFixed(0), - 'Contribution', - Icons.trending_up, - AppColors.brandGreenLight, - trend: 'up', - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildMetricCard( - 'Nouveaux', - newThisMonth.toString(), - 'Ce mois', - Icons.new_releases, - AppColors.warning, - trend: newThisMonth > 0 ? 'up' : 'stable', - ), - ), - ], - ), - ], - ); - } - - /// Carte de métrique avec design sophistiqué - Widget _buildMetricCard( - String title, - String value, - String subtitle, - IconData icon, - Color color, { - String trend = 'stable', - }) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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), - ), - const Spacer(), - if (trend == 'up') - const Icon(Icons.trending_up, color: Colors.green, size: 16) - else if (trend == 'down') - const Icon(Icons.trending_down, color: Colors.red, size: 16) - else - const Icon(Icons.trending_flat, color: Colors.grey, size: 16), - ], - ), - const SizedBox(height: 12), - Text( - value, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 4), - Text( - title, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.textSecondaryLight, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: const TextStyle( - fontSize: 10, - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ); - } - - /// Barre de recherche et filtres avancés - Widget _buildSearchAndFilters() { - return Column( - children: [ - // Barre de recherche principale - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - const Icon(Icons.search, color: AppColors.textSecondaryLight), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: 'Rechercher par nom, email, département...', - border: InputBorder.none, - hintStyle: TextStyle(color: AppColors.textSecondaryLight), - ), - onChanged: (value) { - setState(() { - _searchQuery = value; - }); - }, - ), - ), - if (_searchQuery.isNotEmpty) - IconButton( - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, - icon: const Icon(Icons.clear, color: AppColors.textSecondaryLight), - ), - const SizedBox(width: 8), - Container( - height: 32, - width: 1, - color: AppColors.lightBorder, - ), - const SizedBox(width: 8), - IconButton( - onPressed: () { - setState(() { - _showAdvancedFilters = !_showAdvancedFilters; - }); - }, - icon: Icon( - _showAdvancedFilters ? Icons.filter_list_off : Icons.filter_list, - color: _showAdvancedFilters ? AppColors.primaryGreen : AppColors.textSecondaryLight, - ), - tooltip: 'Filtres avancés', - ), - IconButton( - onPressed: () { - setState(() { - _isGridView = !_isGridView; - }); - }, - icon: Icon( - _isGridView ? Icons.view_list : Icons.grid_view, - color: AppColors.textSecondaryLight, - ), - tooltip: _isGridView ? 'Vue liste' : 'Vue grille', - ), - ], - ), - ), - - // Filtres avancés (conditionnels) - if (_showAdvancedFilters) ...[ - const SizedBox(height: 12), - _buildAdvancedFilters(), - ], - - // Barre de filtres rapides - const SizedBox(height: 12), - _buildQuickFilters(), - ], - ); - } - - /// Filtres rapides horizontaux - Widget _buildQuickFilters() { - final filters = ['Tous', 'Actifs', 'Inactifs', 'Nouveaux', 'Suspendus']; - - return SizedBox( - height: 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: filters.length, - itemBuilder: (context, index) { - final filter = filters[index]; - final isSelected = _selectedFilter == filter; - - return Padding( - padding: EdgeInsets.only( - left: index == 0 ? 0 : 8, - right: index == filters.length - 1 ? 0 : 0, - ), - child: FilterChip( - label: Text(filter), - selected: isSelected, - onSelected: (selected) { - setState(() { - _selectedFilter = selected ? filter : 'Tous'; - }); - }, - backgroundColor: Colors.white, - selectedColor: AppColors.primaryGreen.withOpacity(0.1), - checkmarkColor: AppColors.primaryGreen, - labelStyle: TextStyle( - color: isSelected ? AppColors.primaryGreen : AppColors.textSecondaryLight, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - side: BorderSide( - color: isSelected ? AppColors.primaryGreen : AppColors.lightBorder, - ), - ), - ); - }, - ), - ); - } - - /// Filtres avancés extensibles - Widget _buildAdvancedFilters() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.lightBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Filtres Avancés', - style: TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.textPrimaryLight, - ), - ), - const SizedBox(height: 12), - - // Filtre par rôles - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - 'Membre Actif', - 'Modérateur', - 'Administrateur Org', - 'Gestionnaire RH', - 'Consultant', - 'Membre Simple', - ].map((role) { - final isSelected = _selectedRoles.contains(role); - return FilterChip( - label: Text(role), - selected: isSelected, - onSelected: (selected) { - setState(() { - if (selected) { - _selectedRoles.add(role); - } else { - _selectedRoles.remove(role); - } - }); - }, - backgroundColor: Colors.grey[50], - selectedColor: AppColors.primaryGreen.withOpacity(0.1), - checkmarkColor: AppColors.primaryGreen, - labelStyle: TextStyle( - color: isSelected ? AppColors.primaryGreen : AppColors.textSecondaryLight, - fontSize: 12, - ), - side: BorderSide( - color: isSelected ? AppColors.primaryGreen : AppColors.lightBorder, - ), - ); - }).toList(), - ), - - const SizedBox(height: 12), - - // Actions de filtres - Row( - children: [ - TextButton.icon( - onPressed: () { - setState(() { - _selectedRoles.clear(); - }); - }, - icon: const Icon(Icons.clear_all, size: 16), - label: const Text('Réinitialiser'), - style: TextButton.styleFrom( - foregroundColor: AppColors.textSecondaryLight, - ), - ), - const Spacer(), - Text( - '${_getFilteredMembers().length} résultat(s)', - style: const TextStyle( - color: AppColors.textSecondaryLight, - fontSize: 12, - ), - ), - ], - ), - ], - ), - ); - } - - /// Onglets de catégories - Widget _buildCategoryTabs() { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Tous', icon: Icon(Icons.people, size: 18)), - Tab(text: 'Actifs', icon: Icon(Icons.check_circle, size: 18)), - Tab(text: 'Équipes', icon: Icon(Icons.groups, size: 18)), - Tab(text: 'Analytics', icon: Icon(Icons.analytics, size: 18)), - ], - labelColor: AppColors.primaryGreen, - unselectedLabelColor: AppColors.textSecondaryLight, - indicatorColor: AppColors.primaryGreen, - indicatorWeight: 3, - labelStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - ); - } - - /// Affichage principal des membres (liste ou grille) - Widget _buildMembersDisplay() { - final filteredMembers = _getFilteredMembers(); - - if (filteredMembers.isEmpty) { - return _buildEmptyState(); - } - - return SizedBox( - height: 600, // Hauteur fixe pour éviter les problèmes de layout - child: TabBarView( - controller: _tabController, - children: [ - // Onglet "Tous" - _buildMembersList(filteredMembers), - // Onglet "Actifs" - _buildMembersList(filteredMembers.where((m) => m['status'] == 'Actif').toList()), - // Onglet "Équipes" - _buildTeamsView(filteredMembers), - // Onglet "Analytics" - _buildAnalyticsView(filteredMembers), - ], - ), - ); - } - - /// Liste des membres avec design sophistiqué - Widget _buildMembersList(List> members) { - if (_isGridView) { - return _buildMembersGrid(members); - } - - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: members.length, - itemBuilder: (context, index) { - final member = members[index]; - return _buildMemberCard(member); - }, - ); - } - - /// Grille des membres - Widget _buildMembersGrid(List> members) { - return GridView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.8, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: members.length, - itemBuilder: (context, index) { - final member = members[index]; - return _buildMemberGridCard(member); - }, - ); - } - - /// Carte de membre sophistiquée pour la vue liste - Widget _buildMemberCard(Map member) { - - final joinDate = member['joinDate'] as DateTime; - final lastActivity = member['lastActivity'] as DateTime; - final contributionScore = member['contributionScore'] as int; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: InkWell( - onTap: () => _showMemberDetails(member), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - // Avatar avec indicateur de statut - Stack( - children: [ - CircleAvatar( - radius: 24, - backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), - child: Text( - member['name'][0].toUpperCase(), - style: TextStyle( - color: _getStatusColor(member['status']), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: _getStatusColor(member['status']), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - ), - ), - ], - ), - const SizedBox(width: 12), - - // Informations principales - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - member['name'], - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: AppColors.textPrimaryLight, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: _getRoleColor(member['role']).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - member['role'], - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _getRoleColor(member['role']), - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - member['email'], - style: const TextStyle( - color: AppColors.textSecondaryLight, - fontSize: 14, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.business, - size: 12, - color: Colors.grey[500], - ), - const SizedBox(width: 4), - Text( - member['department'], - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondaryLight, - ), - ), - const SizedBox(width: 12), - const Icon( - Icons.location_on, - size: 12, - color: AppColors.textSecondaryLight, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - member['location'], - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondaryLight, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - // Score de contribution - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _getScoreColor(contributionScore).withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.star, - size: 10, - color: _getScoreColor(contributionScore), - ), - const SizedBox(width: 2), - Text( - contributionScore.toString(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _getScoreColor(contributionScore), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Text( - 'Rejoint ${_formatDate(joinDate)}', - style: const TextStyle( - fontSize: 10, - color: AppColors.textSecondaryLight, - ), - ), - const Spacer(), - Text( - 'Actif ${_formatRelativeTime(lastActivity)}', - style: const TextStyle( - fontSize: 10, - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ], - ), - ), - - // Actions - PopupMenuButton( - onSelected: (value) => _handleMemberAction(value, member), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'view', - child: Row( - children: [ - Icon(Icons.visibility, size: 16), - SizedBox(width: 8), - Text('Voir le profil'), - ], - ), - ), - const PopupMenuItem( - value: 'edit', - child: Row( - children: [ - Icon(Icons.edit, size: 16), - SizedBox(width: 8), - Text('Modifier'), - ], - ), - ), - const PopupMenuItem( - value: 'message', - child: Row( - children: [ - Icon(Icons.message, size: 16), - SizedBox(width: 8), - Text('Envoyer un message'), - ], - ), - ), - const PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon(Icons.delete, size: 16, color: Colors.red), - SizedBox(width: 8), - Text('Supprimer', style: TextStyle(color: Colors.red)), - ], - ), - ), - ], - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.more_vert, - size: 16, - color: AppColors.textSecondaryLight, - ), - ), - ), - ], - ), - ), - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // MÉTHODES UTILITAIRES ET HELPERS - // ═══════════════════════════════════════════════════════════════════════════ - - /// Filtre les membres selon les critères sélectionnés - List> _getFilteredMembers() { - return _allMembers.where((member) { - // Filtre par recherche textuelle - if (_searchQuery.isNotEmpty) { - final query = _searchQuery.toLowerCase(); - final name = member['name'].toString().toLowerCase(); - final email = member['email'].toString().toLowerCase(); - final department = member['department'].toString().toLowerCase(); - - if (!name.contains(query) && - !email.contains(query) && - !department.contains(query)) { - return false; - } - } - - // Filtre par statut rapide - if (_selectedFilter != 'Tous') { - switch (_selectedFilter) { - case 'Actifs': - if (member['status'] != 'Actif') return false; - break; - case 'Inactifs': - if (member['status'] != 'Inactif') return false; - break; - case 'Nouveaux': - final joinDate = member['joinDate'] as DateTime; - final now = DateTime.now(); - final isNewThisMonth = joinDate.year == now.year && joinDate.month == now.month; - if (!isNewThisMonth) return false; - break; - case 'Suspendus': - if (member['status'] != 'Suspendu') return false; - break; - } - } - - // Filtre par rôles sélectionnés - if (_selectedRoles.isNotEmpty && !_selectedRoles.contains(member['role'])) { - return false; - } - - return true; - }).toList(); - } - - /// Obtient la couleur selon le statut - Color _getStatusColor(String status) { - switch (status) { - case 'Actif': - return AppColors.success; - case 'Inactif': - return AppColors.textSecondaryLight; - case 'Suspendu': - return AppColors.error; - case 'En attente': - return AppColors.warning; - default: - return AppColors.textSecondaryLight; - } - } - - /// Obtient la couleur selon le rôle - Color _getRoleColor(String role) { - switch (role) { - case 'Super Administrateur': - return AppColors.brandGreen; - case 'Administrateur Org': - return AppColors.primaryGreen; - case 'Gestionnaire RH': - return AppColors.info; - case 'Modérateur': - return AppColors.brandGreenLight; - case 'Membre Actif': - return AppColors.primaryGreen; - case 'Consultant': - return AppColors.warning; - case 'Membre Simple': - return AppColors.textSecondaryLight; - default: - return AppColors.textSecondaryLight; - } - } - - /// Obtient la couleur selon le score de contribution - Color _getScoreColor(int score) { - if (score >= 90) return AppColors.success; - if (score >= 70) return AppColors.brandGreenLight; - if (score >= 50) return AppColors.warning; - return AppColors.error; - } - - /// Formate une date - String _formatDate(DateTime date) { - final months = [ - 'jan', 'fév', 'mar', 'avr', 'mai', 'jun', - 'jul', 'aoû', 'sep', 'oct', 'nov', 'déc' - ]; - return '${date.day} ${months[date.month - 1]} ${date.year}'; - } - - /// Formate un temps relatif - String _formatRelativeTime(DateTime date) { - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inDays > 30) { - return 'il y a ${(difference.inDays / 30).floor()} mois'; - } else if (difference.inDays > 0) { - return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}'; - } else if (difference.inHours > 0) { - return 'il y a ${difference.inHours}h'; - } else { - return 'à l\'instant'; - } - } - - - - /// Vérifie si l'utilisateur peut gérer les membres - bool _canManageMembers(UserRole role) { - return [ - UserRole.superAdmin, - UserRole.orgAdmin, - UserRole.moderator, - ].contains(role); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // MÉTHODES D'ACTIONS ET DIALOGS - // ═══════════════════════════════════════════════════════════════════════════ - - /// Affiche le dialog d'ajout de membre - void _showAddMemberDialog() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Ajouter un membre'), - content: const Text( - 'Pour enregistrer un nouveau membre, créez une adhésion depuis le module Adhésions.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper()), - ); - }, - child: const Text('Créer une adhésion'), - ), - ], - ), - ); - } - - /// Affiche les actions groupées - void _showBulkActions() { - showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.file_download_outlined), - title: const Text('Exporter la sélection'), - onTap: () { - Navigator.pop(context); - _exportMembers(); - }, - ), - ListTile( - leading: const Icon(Icons.email_outlined), - title: const Text('Envoyer un message groupé'), - onTap: () { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Messagerie groupée à venir. Utilisez l\'action « Message » sur un membre.'), - backgroundColor: AppColors.primaryGreen, - ), - ); - }, - ), - ], - ), - ), - ); - } - - /// Exporte la liste des membres - void _exportMembers() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export des membres en cours...'), - backgroundColor: AppColors.success, - ), - ); - } - - /// Affiche les détails d'un membre - void _showMemberDetails(Map member) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => _buildMemberDetailsSheet(member), - ); - } - - /// Gère les actions sur un membre - void _handleMemberAction(String action, Map member) { - switch (action) { - case 'view': - _showMemberDetails(member); - break; - case 'edit': - _showEditMemberDialog(member); - break; - case 'message': - _sendMessageToMember(member); - break; - case 'delete': - _showDeleteMemberDialog(member); - break; - } - } - - /// Dialog d'édition de membre : ouvre la fiche détail (édition complète à venir) - void _showEditMemberDialog(Map member) { - Navigator.of(context).pop(); // ferme le dialog éventuel - _showMemberDetails(member); - } - - /// Envoie un message à un membre - void _sendMessageToMember(Map member) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Message à ${member['name']} à implémenter'), - backgroundColor: AppColors.info, - ), - ); - } - - /// Dialog de suppression de membre - void _showDeleteMemberDialog(Map member) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Supprimer le membre'), - content: Text('Êtes-vous sûr de vouloir supprimer ${member['name']} ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${member['name']} supprimé'), - backgroundColor: AppColors.error, - ), - ); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('Supprimer'), - ), - ], - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // WIDGETS SPÉCIALISÉS ET VUES AVANCÉES - // ═══════════════════════════════════════════════════════════════════════════ - - /// Carte de membre pour la vue grille - Widget _buildMemberGridCard(Map member) { - final contributionScore = member['contributionScore'] as int; - - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: InkWell( - onTap: () => _showMemberDetails(member), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // Avatar et statut - Stack( - children: [ - CircleAvatar( - radius: 30, - backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), - child: Text( - member['name'][0].toUpperCase(), - style: TextStyle( - color: _getStatusColor(member['status']), - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: _getStatusColor(member['status']), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - - // Nom et rôle - Text( - member['name'], - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: _getRoleColor(member['role']).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - member['role'], - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _getRoleColor(member['role']), - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 8), - - // Score de contribution - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.star, - size: 14, - color: _getScoreColor(contributionScore), - ), - const SizedBox(width: 4), - Text( - contributionScore.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: _getScoreColor(contributionScore), - ), - ), - ], - ), - const Spacer(), - - // Actions rapides - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - onPressed: () => _showMemberDetails(member), - icon: const Icon(Icons.visibility, size: 16), - tooltip: 'Voir', - ), - IconButton( - onPressed: () => _sendMessageToMember(member), - icon: const Icon(Icons.message, size: 16), - tooltip: 'Message', - ), - ], - ), - ], - ), - ), - ), - ); - } - - /// État vide quand aucun membre ne correspond aux filtres - Widget _buildEmptyState() { - return SizedBox( - height: 400, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppColors.primaryGreen.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.people_outline, - size: 48, - color: AppColors.primaryGreen, - ), - ), - const SizedBox(height: 16), - const Text( - 'Aucun membre trouvé', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppColors.textPrimaryLight, - ), - ), - const SizedBox(height: 8), - Text( - _searchQuery.isNotEmpty - ? 'Aucun membre ne correspond à votre recherche' - : 'Aucun membre ne correspond aux filtres sélectionnés', - style: const TextStyle( - color: AppColors.textSecondaryLight, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () { - setState(() { - _searchController.clear(); - _searchQuery = ''; - _selectedFilter = 'Tous'; - _selectedRoles.clear(); - }); - }, - icon: const Icon(Icons.refresh), - label: const Text('Réinitialiser les filtres'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryGreen, - foregroundColor: Colors.white, - ), - ), - ], - ), - ); - } - - /// Vue des équipes (onglet Équipes) - Widget _buildTeamsView(List> members) { - final departments = >>{}; - - // Grouper par département - for (final member in members) { - final dept = member['department'] as String; - departments[dept] = departments[dept] ?? []; - departments[dept]!.add(member); - } - - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: departments.length, - itemBuilder: (context, index) { - final dept = departments.keys.elementAt(index); - final deptMembers = departments[dept]!; - - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: ExpansionTile( - title: Text( - dept, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - subtitle: Text('${deptMembers.length} membre(s)'), - children: deptMembers.map((member) => ListTile( - leading: CircleAvatar( - backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), - child: Text( - member['name'][0].toUpperCase(), - style: TextStyle( - color: _getStatusColor(member['status']), - fontWeight: FontWeight.bold, - ), - ), - ), - title: Text(member['name']), - subtitle: Text(member['role']), - trailing: Text( - member['contributionScore'].toString(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: _getScoreColor(member['contributionScore']), - ), - ), - onTap: () => _showMemberDetails(member), - )).toList(), - ), - ); - }, - ); - } - - /// Vue analytics (onglet Analytics) - Widget _buildAnalyticsView(List> members) { - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - children: [ - // Graphique de répartition par statut - _buildStatusChart(members), - const SizedBox(height: 16), - - // Graphique de répartition par rôle - _buildRoleChart(members), - const SizedBox(height: 16), - - // Top contributeurs - _buildTopContributors(members), - ], - ), - ); - } - - /// Graphique de répartition par statut - Widget _buildStatusChart(List> members) { - final statusCounts = {}; - for (final member in members) { - final status = member['status'] as String; - statusCounts[status] = (statusCounts[status] ?? 0) + 1; - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Répartition par Statut', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - ...statusCounts.entries.map((entry) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: _getStatusColor(entry.key), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Expanded(child: Text(entry.key)), - Text( - entry.value.toString(), - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ], - ), - )), - ], - ), - ), - ); - } - - /// Graphique de répartition par rôle - Widget _buildRoleChart(List> members) { - final roleCounts = {}; - for (final member in members) { - final role = member['role'] as String; - roleCounts[role] = (roleCounts[role] ?? 0) + 1; - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Répartition par Rôle', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - ...roleCounts.entries.map((entry) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: _getRoleColor(entry.key), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Expanded(child: Text(entry.key)), - Text( - entry.value.toString(), - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ], - ), - )), - ], - ), - ), - ); - } - - /// Top contributeurs - Widget _buildTopContributors(List> members) { - final sortedMembers = List>.from(members); - sortedMembers.sort((a, b) => (b['contributionScore'] as int).compareTo(a['contributionScore'] as int)); - final topMembers = sortedMembers.take(5).toList(); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Top Contributeurs', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - ...topMembers.asMap().entries.map((entry) { - final index = entry.key; - final member = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: index < 3 ? AppColors.warning : AppColors.textSecondaryLight, - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - '${index + 1}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 12), - CircleAvatar( - radius: 16, - backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), - child: Text( - member['name'][0].toUpperCase(), - style: TextStyle( - color: _getStatusColor(member['status']), - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - member['name'], - style: const TextStyle(fontWeight: FontWeight.w500), - ), - Text( - member['role'], - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _getScoreColor(member['contributionScore']).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.star, - size: 12, - color: _getScoreColor(member['contributionScore']), - ), - const SizedBox(width: 2), - Text( - member['contributionScore'].toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: _getScoreColor(member['contributionScore']), - ), - ), - ], - ), - ), - ], - ), - ); - }), - ], - ), - ), - ); - } - - /// Sheet de détails d'un membre - Widget _buildMemberDetailsSheet(Map member) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Handle - Container( - margin: const EdgeInsets.symmetric(vertical: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - - // Header - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - CircleAvatar( - radius: 30, - backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), - child: Text( - member['name'][0].toUpperCase(), - style: TextStyle( - color: _getStatusColor(member['status']), - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - member['name'], - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - Text( - member['role'], - style: TextStyle( - color: _getRoleColor(member['role']), - fontWeight: FontWeight.w500, - ), - ), - Container( - margin: const EdgeInsets.only(top: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: _getStatusColor(member['status']).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - member['status'], - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: _getStatusColor(member['status']), - ), - ), - ), - ], - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - ), - - // Contenu détaillé - Expanded( - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Informations de contact - _buildDetailSection( - 'Informations de Contact', - [ - _buildDetailItem(Icons.email, 'Email', member['email']), - _buildDetailItem(Icons.phone, 'Téléphone', member['phone']), - _buildDetailItem(Icons.location_on, 'Localisation', member['location']), - ], - ), - - // Informations professionnelles - _buildDetailSection( - 'Informations Professionnelles', - [ - _buildDetailItem(Icons.business, 'Département', member['department']), - _buildDetailItem(Icons.admin_panel_settings, 'Permissions', '${member['permissions']} permissions'), - _buildDetailItem(Icons.calendar_today, 'Date d\'adhésion', _formatDate(member['joinDate'])), - _buildDetailItem(Icons.access_time, 'Dernière activité', _formatRelativeTime(member['lastActivity'])), - ], - ), - - // Statistiques d'activité - _buildDetailSection( - 'Statistiques d\'Activité', - [ - _buildDetailItem(Icons.star, 'Score de contribution', '${member['contributionScore']}/100'), - _buildDetailItem(Icons.event, 'Événements participés', '${member['eventsAttended']} événements'), - _buildDetailItem(Icons.work, 'Projets impliqués', '${member['projectsInvolved']} projets'), - ], - ), - - const SizedBox(height: 20), - - // Actions - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - _showEditMemberDialog(member); - }, - icon: const Icon(Icons.edit), - label: const Text('Modifier'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryGreen, - foregroundColor: Colors.white, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - _sendMessageToMember(member); - }, - icon: const Icon(Icons.message), - label: const Text('Message'), - ), - ), - ], - ), - ], - ), - ), - ), - ], - ), - ); - }, - ); - } - - /// Section de détails - Widget _buildDetailSection(String title, List items) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimaryLight, - ), - ), - const SizedBox(height: 12), - ...items, - const SizedBox(height: 20), - ], - ); - } - - /// Item de détail - Widget _buildDetailItem(IconData icon, String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Icon( - icon, - size: 20, - color: AppColors.textSecondaryLight, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondaryLight, - ), - ), - Text( - value, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimaryLight, - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/members/presentation/pages/members_page_connected.dart b/lib/features/members/presentation/pages/members_page_connected.dart index eff3c6e..629fd8f 100644 --- a/lib/features/members/presentation/pages/members_page_connected.dart +++ b/lib/features/members/presentation/pages/members_page_connected.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import '../../../../shared/design_system/unionflow_design_v2.dart'; import '../../../../shared/design_system/components/uf_app_bar.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../features/organizations/domain/repositories/organization_repository.dart'; /// Annuaire des Membres - Design UnionFlow class MembersPageWithDataAndPagination extends StatefulWidget { @@ -14,6 +16,16 @@ class MembersPageWithDataAndPagination extends StatefulWidget { final VoidCallback onRefresh; final void Function(String? query)? onSearch; final VoidCallback? onAddMember; + /// null = SUPER_ADMIN (vue globale, affiche l'organisation sur chaque carte) + final String? organisationId; + /// Callback déclenché quand l'admin active un membre en attente + final void Function(String memberId)? onActivateMember; + /// Callback déclenché quand l'admin réinitialise le mot de passe d'un membre + final void Function(String memberId)? onResetPassword; + /// Callback déclenché quand le superadmin affecte un membre à une organisation + final void Function(String memberId, String organisationId)? onAffecterOrganisation; + /// Callback pour les actions de cycle de vie adhésion (admin org) + final void Function(String memberId, String action, String? motif)? onLifecycleAction; const MembersPageWithDataAndPagination({ super.key, @@ -25,6 +37,11 @@ class MembersPageWithDataAndPagination extends StatefulWidget { required this.onRefresh, this.onSearch, this.onAddMember, + this.organisationId, + this.onActivateMember, + this.onResetPassword, + this.onAffecterOrganisation, + this.onLifecycleAction, }); @override @@ -37,6 +54,11 @@ class _MembersPageWithDataAndPaginationState extends State> _organisationsPicker = []; + + bool get _isSuperAdmin => widget.organisationId == null; + @override void dispose() { _searchDebounce?.cancel(); @@ -74,51 +96,55 @@ class _MembersPageWithDataAndPaginationState extends State m['status'] == 'Actif').length; - final pendingCount = widget.members.where((m) => m['status'] == 'En attente').length; + final pageMembers = widget.members; + final activeCount = pageMembers.where((m) => m['status'] == 'Actif').length; + final pendingCount = pageMembers.where((m) => m['status'] == 'En attente').length; return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: UnionFlowColors.surface, - border: Border( - bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1), - ), + border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), ), child: Row( children: [ - Expanded(child: _buildStatBadge('Total', widget.totalCount.toString(), UnionFlowColors.unionGreen)), - const SizedBox(width: 12), - Expanded(child: _buildStatBadge('Actifs', activeCount.toString(), UnionFlowColors.success)), - const SizedBox(width: 12), - Expanded(child: _buildStatBadge('Attente', pendingCount.toString(), UnionFlowColors.warning)), + Expanded(child: _buildStatBadge('Total', widget.totalCount.toString(), UnionFlowColors.unionGreen, subtitle: 'global')), + const SizedBox(width: 8), + Expanded(child: _buildStatBadge('Actifs', activeCount.toString(), UnionFlowColors.success, subtitle: 'cette page')), + const SizedBox(width: 8), + Expanded(child: _buildStatBadge('Attente', pendingCount.toString(), UnionFlowColors.warning, subtitle: 'cette page')), ], ), ); } - Widget _buildStatBadge(String label, String value, Color color) { + Widget _buildStatBadge(String label, String value, Color color, {String? subtitle}) { return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.3), width: 1), + border: Border.all(color: color.withOpacity(0.25), width: 1), ), child: Column( children: [ - Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)), - const SizedBox(height: 2), + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: color)), Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)), + if (subtitle != null) + Text(subtitle, style: TextStyle(fontSize: 9, color: color.withOpacity(0.6))), ], ), ); } + // ── Recherche + Filtres ──────────────────────────────────────────────────── + Widget _buildSearchAndFilters() { return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), decoration: BoxDecoration( color: UnionFlowColors.surface, border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), @@ -136,7 +162,7 @@ class _MembersPageWithDataAndPaginationState extends State setState(() => _filterStatus = label), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), + color: isSelected ? UnionFlowColors.unionGreen : Colors.transparent, + borderRadius: BorderRadius.circular(20), border: Border.all(color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.border, width: 1), ), child: Text( @@ -210,11 +228,14 @@ class _MembersPageWithDataAndPaginationState extends State widget.onRefresh(), color: UnionFlowColors.unionGreen, child: ListView.separated( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), itemCount: filtered.length, separatorBuilder: (_, __) => const SizedBox(height: 6), itemBuilder: (context, index) => _buildMemberCard(filtered[index]), @@ -233,11 +254,17 @@ class _MembersPageWithDataAndPaginationState extends State member) { + final String? orgName = member['organisationNom'] as String?; + final String? numero = member['numeroMembre'] as String?; + final String status = member['status'] as String? ?? '?'; + return GestureDetector( onTap: () => _showMemberDetails(member), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: UnionFlowColors.surface, borderRadius: BorderRadius.circular(10), @@ -245,104 +272,183 @@ class _MembersPageWithDataAndPaginationState extends State (UnionFlowColors.success, Icons.check_circle_outline), + 'Inactif' => (UnionFlowColors.error, Icons.cancel_outlined), + 'En attente' => (UnionFlowColors.warning, Icons.schedule_outlined), + 'Suspendu' => (const Color(0xFF9E9E9E), Icons.block_outlined), + _ => (UnionFlowColors.textSecondary, Icons.help_outline), + }; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.3), width: 1), ), - child: Text(status ?? '?', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), - ); - } - - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration(color: UnionFlowColors.unionGreenPale, shape: BoxShape.circle), - child: const Icon(Icons.people_outline, size: 40, color: UnionFlowColors.unionGreen), - ), - const SizedBox(height: 12), - const Text( - 'Aucun membre trouvé', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), - ), - const SizedBox(height: 8), - Text( - _searchQuery.isEmpty ? 'Changez vos filtres' : 'Essayez une autre recherche', - style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), - ), + Icon(icon, size: 11, color: color), + const SizedBox(width: 4), + Text(status, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), ], ), ); } + // ── État vide ───────────────────────────────────────────────────────────── + + Widget _buildEmptyState() { + final hasActiveFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: const BoxDecoration(color: UnionFlowColors.unionGreenPale, shape: BoxShape.circle), + child: Icon( + hasActiveFilters ? Icons.filter_list_off : Icons.people_outline, + size: 40, + color: UnionFlowColors.unionGreen, + ), + ), + const SizedBox(height: 16), + Text( + hasActiveFilters ? 'Aucun résultat' : 'Aucun membre', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + const SizedBox(height: 8), + Text( + hasActiveFilters + ? 'Modifiez la recherche ou le filtre de statut' + : 'Aucun membre enregistré pour le moment', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), + ), + if (hasActiveFilters) ...[ + const SizedBox(height: 16), + GestureDetector( + onTap: () { + setState(() { + _filterStatus = 'Tous'; + _searchQuery = ''; + _searchController.clear(); + }); + widget.onSearch?.call(null); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: UnionFlowColors.unionGreen), + borderRadius: BorderRadius.circular(20), + ), + child: const Text('Effacer les filtres', style: TextStyle(fontSize: 13, color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w600)), + ), + ), + ], + ], + ), + ), + ); + } + + // ── Pagination ──────────────────────────────────────────────────────────── + Widget _buildPagination() { return Container( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: UnionFlowColors.surface, border: Border(top: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), @@ -354,10 +460,7 @@ class _MembersPageWithDataAndPaginationState extends State 0 ? UnionFlowColors.unionGreen : UnionFlowColors.textTertiary, onPressed: widget.currentPage > 0 - ? () => widget.onPageChanged( - widget.currentPage - 1, - _searchQuery.isEmpty ? null : _searchQuery, - ) + ? () => widget.onPageChanged(widget.currentPage - 1, _searchQuery.isEmpty ? null : _searchQuery) : null, ), Container( @@ -372,10 +475,7 @@ class _MembersPageWithDataAndPaginationState extends State widget.onPageChanged( - widget.currentPage + 1, - _searchQuery.isEmpty ? null : _searchQuery, - ) + ? () => widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery) : null, ), ], @@ -383,61 +483,434 @@ class _MembersPageWithDataAndPaginationState extends State member) { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 56, - decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle), - alignment: Alignment.center, - child: Text( - member['initiales'] ?? '??', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22), + isScrollControlled: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.55, + minChildSize: 0.4, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) => Container( + decoration: const BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Drag handle + Container( + margin: const EdgeInsets.only(top: 10), + width: 36, + height: 4, + decoration: BoxDecoration(color: UnionFlowColors.border, borderRadius: BorderRadius.circular(2)), ), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avatar + nom + statut + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + member['initiales'] as String? ?? '??', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member['name'] as String? ?? '', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + const SizedBox(height: 3), + Text( + member['role'] as String? ?? 'Membre', + style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), + ), + if (member['numeroMembre'] != null) ...[ + const SizedBox(height: 3), + Text( + member['numeroMembre'] as String, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: UnionFlowColors.textTertiary), + ), + ], + ], + ), + ), + _buildStatusBadge(member['status'] as String? ?? '?'), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 12), + // Infos contact + _buildSectionTitle('Contact'), + _buildDetailRow(Icons.email_outlined, 'Email', member['email'] as String? ?? '—'), + if ((member['phone'] as String? ?? '').isNotEmpty) + _buildDetailRow(Icons.phone_outlined, 'Téléphone', member['phone'] as String), + const SizedBox(height: 12), + // Infos organisation + if (member['organisationNom'] != null || member['organisationId'] != null) ...[ + _buildSectionTitle('Organisation'), + if (member['organisationNom'] != null) + _buildDetailRow(Icons.business_outlined, 'Organisation', member['organisationNom'] as String), + if (member['dateAdhesion'] != null) + _buildDetailRow(Icons.calendar_today_outlined, 'Adhésion', _formatDate(member['dateAdhesion'])), + const SizedBox(height: 12), + ], + // Infos pro + if ((member['department'] as String? ?? '').isNotEmpty) ...[ + _buildSectionTitle('Profil'), + _buildDetailRow(Icons.work_outline, 'Profession', member['department'] as String), + if ((member['nationalite'] as String? ?? '').isNotEmpty) + _buildDetailRow(Icons.flag_outlined, 'Nationalité', member['nationalite'] as String), + if ((member['location'] as String? ?? ', ').trim() != ',') + _buildDetailRow(Icons.location_on_outlined, 'Localisation', member['location'] as String), + ], + // Bouton d'activation (ADMIN_ORGANISATION uniquement, membre en attente) + if (member['status'] == 'En attente' && widget.onActivateMember != null) ...[ + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + widget.onActivateMember!(member['id'] as String); + }, + icon: const Icon(Icons.check_circle_outline, size: 18), + label: const Text('Activer le membre', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + // Bouton reset mot de passe (tous membres avec compte Keycloak) + if (widget.onResetPassword != null) ...[ + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.pop(context); + widget.onResetPassword!(member['id'] as String); + }, + icon: const Icon(Icons.lock_reset_outlined, size: 18), + label: const Text('Réinitialiser le mot de passe', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: UnionFlowColors.warning, + side: const BorderSide(color: UnionFlowColors.warning), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + // Bouton affectation organisation (superadmin, membre sans organisation) + if (_isSuperAdmin && + widget.onAffecterOrganisation != null && + (member['organisationId'] == null || (member['organisationId'] as String).isEmpty)) ...[ + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showAffecterOrganisationDialog(context, member), + icon: const Icon(Icons.business_outlined, size: 18), + label: const Text('Affecter à une organisation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: UnionFlowColors.unionGreen, + side: const BorderSide(color: UnionFlowColors.unionGreen), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + // ── Boutons cycle de vie adhésion (ADMIN_ORGANISATION) ── + if (!_isSuperAdmin && widget.onLifecycleAction != null) ...[ + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 8), + _buildLifecycleActionsSection(context, member), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLifecycleActionsSection(BuildContext context, Map member) { + final memberId = member['id'] as String? ?? ''; + final statut = member['statutMembre'] as String? ?? member['status'] as String? ?? ''; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text('Actions adhésion', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.grey)), + ), + // Activer (INVITE ou EN_ATTENTE_VALIDATION) + if (statut == 'INVITE' || statut == 'EN_ATTENTE_VALIDATION' || statut == 'En attente') + _lifecycleButton( + context: context, + label: 'Activer l\'adhésion', + icon: Icons.check_circle_outline, + color: Colors.green, + onPressed: () { + Navigator.pop(context); + widget.onLifecycleAction!(memberId, 'activer', null); + }, + ), + // Suspendre (ACTIF) + if (statut == 'ACTIF' || statut == 'Actif') + _lifecycleButton( + context: context, + label: 'Suspendre l\'adhésion', + icon: Icons.pause_circle_outline, + color: Colors.orange, + outlined: true, + onPressed: () => _showMotifDialog( + context, + 'Suspendre l\'adhésion', + onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'suspendre', motif), ), - const SizedBox(height: 10), - Text( - member['name'] ?? '', - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + // Réactiver (SUSPENDU) + if (statut == 'SUSPENDU' || statut == 'Suspendu') + _lifecycleButton( + context: context, + label: 'Réactiver l\'adhésion', + icon: Icons.play_circle_outline, + color: Colors.blue, + onPressed: () { + Navigator.pop(context); + widget.onLifecycleAction!(memberId, 'activer', null); + }, + ), + // Radier (tout statut actif) + if (statut != 'RADIE' && statut != 'ARCHIVE' && statut.isNotEmpty) + _lifecycleButton( + context: context, + label: 'Radier de l\'organisation', + icon: Icons.block_outlined, + color: Colors.red, + outlined: true, + onPressed: () => _showMotifDialog( + context, + 'Radier le membre', + onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'radier', motif), ), - const SizedBox(height: 4), - Text( - member['role'] ?? '', - style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), + ), + ], + ); + } + + Widget _lifecycleButton({ + required BuildContext context, + required String label, + required IconData icon, + required Color color, + bool outlined = false, + required VoidCallback onPressed, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: outlined + ? OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: color, + side: BorderSide(color: color), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ) + : ElevatedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ); + } + + void _showMotifDialog( + BuildContext context, + String titre, { + required void Function(String? motif) onConfirm, + }) { + final motifCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(titre), + content: TextField( + controller: motifCtrl, + decoration: const InputDecoration( + labelText: 'Motif (optionnel)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(ctx); // ferme dialog motif + Navigator.pop(context); // ferme bottom sheet + onConfirm(motifCtrl.text.isNotEmpty ? motifCtrl.text : null); + }, + child: const Text('Confirmer'), + ), + ], + ), + ); + } + + Future _showAffecterOrganisationDialog( + BuildContext ctx, + Map member, + ) async { + // Charger les organisations si pas encore fait + if (_organisationsPicker.isEmpty) { + try { + final repo = GetIt.instance(); + final orgs = await repo.getOrganizations(page: 0, size: 100); + if (mounted) { + setState(() { + _organisationsPicker = orgs + .where((o) => o.id != null && o.id!.isNotEmpty) + .map((o) => {'id': o.id!, 'nom': o.nomAffichage}) + .toList(); + }); + } + } catch (_) {} + } + + if (!mounted) return; + + String? selectedOrgId; + + await showDialog( + context: ctx, + builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: const Text('Affecter à une organisation'), + content: _organisationsPicker.isEmpty + ? const Text('Aucune organisation disponible.') + : DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Organisation'), + items: _organisationsPicker + .map((o) => DropdownMenuItem( + value: o['id'], + child: Text(o['nom']!), + )) + .toList(), + onChanged: (v) => setDialogState(() => selectedOrgId = v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: selectedOrgId == null + ? null + : () { + Navigator.pop(dialogCtx); + Navigator.pop(ctx); // ferme le bottom sheet + widget.onAffecterOrganisation!( + member['id'] as String, + selectedOrgId!, + ); + }, + child: const Text('Confirmer'), ), - const SizedBox(height: 12), - _buildInfoRow(Icons.email_outlined, member['email'] ?? 'Non fourni'), - _buildInfoRow(Icons.phone_outlined, member['phone'] ?? 'Non fourni'), - _buildInfoRow(Icons.location_on_outlined, member['location'] ?? 'Non renseigné'), - _buildInfoRow(Icons.work_outline, member['department'] ?? 'Aucun département'), - const SizedBox(height: 12), ], ), ), ); } - Widget _buildInfoRow(IconData icon, String text) { + Widget _buildSectionTitle(String title) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title.toUpperCase(), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: UnionFlowColors.textTertiary, letterSpacing: 0.8), + ), + ); + } + + Widget _buildDetailRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: UnionFlowColors.unionGreen), - const SizedBox(width: 12), - Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary))), + Icon(icon, size: 16, color: UnionFlowColors.unionGreen), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary)), + const SizedBox(height: 1), + Text(value, style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary, fontWeight: FontWeight.w500)), + ], + ), + ), ], ), ); } + + String _formatDate(dynamic date) { + if (date == null) return '—'; + if (date is DateTime) return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + return date.toString(); + } } diff --git a/lib/features/members/presentation/pages/members_page_wrapper.dart b/lib/features/members/presentation/pages/members_page_wrapper.dart index b33010f..af21b90 100644 --- a/lib/features/members/presentation/pages/members_page_wrapper.dart +++ b/lib/features/members/presentation/pages/members_page_wrapper.dart @@ -15,7 +15,7 @@ import '../../bloc/membres_bloc.dart'; import '../../bloc/membres_event.dart'; import '../../bloc/membres_state.dart'; import '../../data/models/membre_complete_model.dart'; -import '../widgets/add_member_dialog.dart'; +import '../widgets/add_member_dialog.dart' show showAddMemberSheet, showCredentialsDialog; import 'members_page_connected.dart'; final _getIt = GetIt.instance; @@ -55,55 +55,14 @@ class MembersPageConnected extends StatelessWidget { Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - // Après création : afficher le mot de passe temporaire si disponible, puis recharger + // Après création : recharger la liste (la dialog mot de passe est gérée dans AddMemberDialog) if (state is MembreCreated) { - final motDePasse = state.membre.motDePasseTemporaire; - if (motDePasse != null && motDePasse.isNotEmpty) { - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => AlertDialog( - title: const Text('Compte créé avec succès'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Le membre ${state.membre.nomComplet} a été créé.'), - const SizedBox(height: 12), - const Text( - 'Mot de passe temporaire :', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - SelectableText( - motDePasse, - style: const TextStyle( - fontSize: 18, - fontFamily: 'monospace', - letterSpacing: 2, - ), - ), - const SizedBox(height: 12), - const Text( - 'Communiquez ce mot de passe au membre. Il devra le changer à sa première connexion.', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - ], - ), - actions: [ - ElevatedButton( - onPressed: () => Navigator.of(_).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } // Gestion des erreurs avec SnackBar if (state is MembresError) { + final bloc = context.read(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), @@ -113,12 +72,67 @@ class MembersPageConnected extends StatelessWidget { label: 'Réessayer', textColor: Colors.white, onPressed: () { - context.read().add(LoadMembres(organisationId: organisationId)); + bloc.add(LoadMembres(organisationId: organisationId)); }, ), ), ); } + + // Après activation : succès + rechargement + if (state is MembreActivated) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre activé avec succès'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + + // Après reset mot de passe : afficher le dialog credentials + if (state is MotDePasseReinitialise) { + showCredentialsDialog(context, state.membre); + } + + // Après affectation à une organisation : succès + rechargement + if (state is MembreAffecte) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre affecté à l\'organisation avec succès'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + + // Lifecycle adhésion : succès + rechargement + if (state is MembreInvite) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invitation envoyée'), backgroundColor: Colors.blue, duration: Duration(seconds: 3)), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + if (state is AdhesionActivee) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Adhésion activée'), backgroundColor: Colors.green, duration: Duration(seconds: 3)), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + if (state is AdhesionSuspendue) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: Colors.orange, duration: Duration(seconds: 3)), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + if (state is MembreRadie) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: Colors.red, duration: Duration(seconds: 3)), + ); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } }, child: BlocBuilder( builder: (context, state) { @@ -178,6 +192,7 @@ class MembersPageConnected extends StatelessWidget { totalCount: state.totalElements, currentPage: state.currentPage, totalPages: state.totalPages, + organisationId: organisationId, onPageChanged: (newPage, recherche) { AppLogger.userAction('Load page', data: {'page': newPage}); context.read().add(LoadMembres(page: newPage, recherche: recherche, organisationId: organisationId)); @@ -189,16 +204,37 @@ class MembersPageConnected extends StatelessWidget { onSearch: (query) { context.read().add(LoadMembres(page: 0, recherche: query, organisationId: organisationId)); }, - onAddMember: () async { - final bloc = context.read(); - await showDialog( - context: context, - builder: (_) => BlocProvider.value( - value: bloc, - child: const AddMemberDialog(), - ), - ); + onAddMember: () => showAddMemberSheet(context), + onActivateMember: (memberId) { + context.read().add(ActivateMembre(memberId)); }, + onResetPassword: (memberId) { + context.read().add(ResetMotDePasse(memberId)); + }, + onAffecterOrganisation: organisationId == null + ? (memberId, orgId) { + context.read().add(AffecterOrganisation(memberId, orgId)); + } + : null, + onLifecycleAction: organisationId != null + ? (memberId, action, motif) { + final bloc = context.read(); + switch (action) { + case 'inviter': + bloc.add(InviterMembre(membreId: memberId, organisationId: organisationId!)); + break; + case 'activer': + bloc.add(ActiverAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); + break; + case 'suspendre': + bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); + break; + case 'radier': + bloc.add(RadierAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); + break; + } + } + : null, ); } diff --git a/lib/features/members/presentation/widgets/add_member_dialog.dart b/lib/features/members/presentation/widgets/add_member_dialog.dart index 3b7697b..ea81bca 100644 --- a/lib/features/members/presentation/widgets/add_member_dialog.dart +++ b/lib/features/members/presentation/widgets/add_member_dialog.dart @@ -1,403 +1,1300 @@ -/// Dialogue d'ajout de membre -/// Formulaire complet pour créer un nouveau membre +/// Bottom sheet d'ajout de membre — Design UnionFlow V2 library add_member_dialog; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../authentication/data/models/user_role.dart'; +import '../../../authentication/presentation/bloc/auth_bloc.dart'; +import '../../../organizations/domain/repositories/organization_repository.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../bloc/membres_bloc.dart'; import '../../bloc/membres_event.dart'; +import '../../bloc/membres_state.dart'; import '../../data/models/membre_complete_model.dart'; -/// Dialogue d'ajout de membre -class AddMemberDialog extends StatefulWidget { - const AddMemberDialog({super.key}); +/// Ouvre le bottom sheet d'ajout de membre. +/// Doit être appelé depuis un contexte qui a accès à [MembresBloc] et [AuthBloc]. +void showAddMemberSheet(BuildContext context) { + final membresBloc = context.read(); + final authState = context.read().state; - @override - State createState() => _AddMemberDialogState(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => BlocProvider.value( + value: membresBloc, + child: AddMemberSheet( + authState: authState, + onCreated: (membre) => _showPasswordDialog(context, membre), + ), + ), + ); } -class _AddMemberDialogState extends State { - final _formKey = GlobalKey(); - - // Contrôleurs de texte - final _nomController = TextEditingController(); - final _prenomController = TextEditingController(); - final _emailController = TextEditingController(); - final _telephoneController = TextEditingController(); - final _adresseController = TextEditingController(); - final _villeController = TextEditingController(); - final _codePostalController = TextEditingController(); - final _regionController = TextEditingController(); - final _paysController = TextEditingController(); - final _professionController = TextEditingController(); - final _nationaliteController = TextEditingController(); - - // Valeurs sélectionnées - Genre? _selectedGenre; - DateTime? _dateNaissance; - final StatutMembre _selectedStatut = StatutMembre.actif; - +// ─── Helpers credentials ───────────────────────────────────────────────────── + +/// Formate le bloc de credentials en texte brut partageable +String _credentialText(String nomComplet, String email, String password) => + ''' +Identifiants UnionFlow +───────────────────────────── +Membre $nomComplet +Email $email +Mot de passe $password +───────────────────────────── +⚠ Changez le mot de passe à la première connexion. +''' + .trim(); + +// ─── Dialog credentials ─────────────────────────────────────────────────────── + +/// Point d'entrée public — utilisable depuis n'importe quel widget (ex: reset mot de passe) +void showCredentialsDialog(BuildContext context, MembreCompletModel membre) => + _showPasswordDialog(context, membre); + +void _showPasswordDialog(BuildContext context, MembreCompletModel membre) { + final username = membre.email; + final password = membre.motDePasseTemporaire; + final nomComplet = '${membre.prenom} ${membre.nom}'; + final credText = password != null + ? _credentialText(nomComplet, username, password) + : null; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogCtx) => _CredentialsDialog( + membre: membre, + username: username, + password: password, + credText: credText, + ), + ); +} + +// ─── Widget dialog ──────────────────────────────────────────────────────────── + +class _CredentialsDialog extends StatefulWidget { + final MembreCompletModel membre; + final String username; + final String? password; + final String? credText; + + const _CredentialsDialog({ + required this.membre, + required this.username, + this.password, + this.credText, + }); + @override - void dispose() { - _nomController.dispose(); - _prenomController.dispose(); - _emailController.dispose(); - _telephoneController.dispose(); - _adresseController.dispose(); - _villeController.dispose(); - _codePostalController.dispose(); - _regionController.dispose(); - _paysController.dispose(); - _professionController.dispose(); - _nationaliteController.dispose(); - super.dispose(); + State<_CredentialsDialog> createState() => _CredentialsDialogState(); +} + +class _CredentialsDialogState extends State<_CredentialsDialog> { + bool _copied = false; + + void _copyAll() { + if (widget.credText == null) return; + Clipboard.setData(ClipboardData(text: widget.credText!)); + setState(() => _copied = true); + Future.delayed(const Duration(seconds: 2), + () { if (mounted) setState(() => _copied = false); }); + } + + Future _shareViaSms() async { + if (widget.credText == null) return; + await Share.share(widget.credText!, subject: 'Identifiants UnionFlow'); + } + + Future _shareViaEmail() async { + if (widget.credText == null) return; + final subject = Uri.encodeComponent('Vos identifiants UnionFlow'); + final body = Uri.encodeComponent(widget.credText!); + final uri = Uri.parse('mailto:${widget.membre.email}?subject=$subject&body=$body'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } } @override Widget build(BuildContext context) { + final nomComplet = '${widget.membre.prenom} ${widget.membre.nom}'; + return Dialog( - child: Container( - width: MediaQuery.of(context).size.width * 0.9, - constraints: const BoxConstraints(maxHeight: 600), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // En-tête - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Color(0xFF6C5CE7), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), - ), - child: Row( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────── + Row( children: [ - const Icon(Icons.person_add, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Ajouter un membre', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: UnionFlowColors.successPale, + borderRadius: BorderRadius.circular(10), ), + child: const Icon(Icons.check_circle_rounded, + color: UnionFlowColors.success, size: 22), ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Compte créé', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary)), + Text(nomComplet, + style: const TextStyle( + fontSize: 12, + color: UnionFlowColors.textSecondary)), + ], + ), ), ], ), - ), - - // Formulaire - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 16), + + // ── Cas sans mot de passe ──────────────────────────────── + if (widget.password == null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UnionFlowColors.infoPale, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.infoLight), + ), + child: const Row( children: [ - // Informations personnelles - _buildSectionTitle('Informations personnelles'), - const SizedBox(height: 12), - - TextFormField( - controller: _nomController, - decoration: const InputDecoration( - labelText: 'Nom *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Le nom est obligatoire'; - } - return null; - }, - ), - const SizedBox(height: 12), - - TextFormField( - controller: _prenomController, - decoration: const InputDecoration( - labelText: 'Prénom *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person_outline), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Le prénom est obligatoire'; - } - return null; - }, - ), - const SizedBox(height: 12), - - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: 'Email *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.email), - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null || value.isEmpty) { - return 'L\'email est obligatoire'; - } - if (!value.contains('@')) { - return 'Email invalide'; - } - return null; - }, - ), - const SizedBox(height: 12), - - TextFormField( - controller: _telephoneController, - decoration: const InputDecoration( - labelText: 'Téléphone', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.phone), - ), - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 12), - - // Genre - DropdownButtonFormField( - value: _selectedGenre, - decoration: const InputDecoration( - labelText: 'Genre', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.wc), - ), - items: Genre.values.map((genre) { - return DropdownMenuItem( - value: genre, - child: Text(_getGenreLabel(genre)), - ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedGenre = value; - }); - }, - ), - const SizedBox(height: 12), - - // Date de naissance - InkWell( - onTap: () => _selectDate(context), - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'Date de naissance', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.calendar_today), - ), - child: Text( - _dateNaissance != null - ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) - : 'Sélectionner une date', - ), - ), - ), - const SizedBox(height: 16), - - // Adresse - _buildSectionTitle('Adresse'), - const SizedBox(height: 12), - - TextFormField( - controller: _adresseController, - decoration: const InputDecoration( - labelText: 'Adresse', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.home), - ), - maxLines: 2, - ), - const SizedBox(height: 12), - - Row( - children: [ - Expanded( - child: TextFormField( - controller: _villeController, - decoration: const InputDecoration( - labelText: 'Ville', - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _codePostalController, - decoration: const InputDecoration( - labelText: 'Code postal', - border: OutlineInputBorder(), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - - TextFormField( - controller: _regionController, - decoration: const InputDecoration( - labelText: 'Région', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - - TextFormField( - controller: _paysController, - decoration: const InputDecoration( - labelText: 'Pays', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - - // Informations professionnelles - _buildSectionTitle('Informations professionnelles'), - const SizedBox(height: 12), - - TextFormField( - controller: _professionController, - decoration: const InputDecoration( - labelText: 'Profession', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.work), - ), - ), - const SizedBox(height: 12), - - TextFormField( - controller: _nationaliteController, - decoration: const InputDecoration( - labelText: 'Nationalité', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.flag), + Icon(Icons.info_outline_rounded, + size: 15, color: UnionFlowColors.info), + SizedBox(width: 8), + Expanded( + child: Text( + 'Provisionnement Keycloak non bloquant — mot de passe à définir manuellement via la console.', + style: TextStyle( + fontSize: 12, + color: UnionFlowColors.textSecondary, + height: 1.4), ), ), ], ), ), - ), - ), - - // Boutons d'action - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey[100], - border: Border(top: BorderSide(color: Colors.grey[300]!)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), + ], + + // ── Bloc credentials ───────────────────────────────────── + if (widget.password != null) ...[ + const Text( + 'IDENTIFIANTS À COMMUNIQUER', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textTertiary, + letterSpacing: 1), + ), + const SizedBox(height: 8), + + // Bloc code sombre + Container( + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFF1E2A1E), // vert ardoise sombre + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2E4A2E)), ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: _submitForm, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barre titre du bloc + Container( + padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), + decoration: const BoxDecoration( + color: Color(0xFF162216), + borderRadius: + BorderRadius.vertical(top: Radius.circular(11)), + border: Border( + bottom: BorderSide(color: Color(0xFF2E4A2E))), + ), + child: Row( + children: [ + const Icon(Icons.terminal_rounded, + size: 13, color: Color(0xFF4CAF50)), + const SizedBox(width: 6), + const Text( + 'credentials.txt', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF90C890), + fontFamily: 'monospace'), + ), + const Spacer(), + GestureDetector( + onTap: _copyAll, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _copied + ? const Row( + key: ValueKey('copied'), + children: [ + Icon(Icons.check_rounded, + size: 13, + color: Color(0xFF4CAF50)), + SizedBox(width: 4), + Text('Copié', + style: TextStyle( + fontSize: 11, + color: Color(0xFF4CAF50), + fontFamily: 'monospace')), + ], + ) + : const Row( + key: ValueKey('copy'), + children: [ + Icon(Icons.copy_rounded, + size: 13, + color: Color(0xFF90C890)), + SizedBox(width: 4), + Text('Copier tout', + style: TextStyle( + fontSize: 11, + color: Color(0xFF90C890), + fontFamily: 'monospace')), + ], + ), + ), + ), + ], + ), + ), + + // Corps du bloc + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _credLine('# Identifiants UnionFlow', + color: const Color(0xFF6A9955), + isComment: true), + const SizedBox(height: 8), + _credLine('email ', + value: widget.username, + labelColor: const Color(0xFF9CDCFE), + valueColor: const Color(0xFFCE9178)), + const SizedBox(height: 6), + _credLine('mot_de_passe', + value: widget.password!, + labelColor: const Color(0xFF9CDCFE), + valueColor: const Color(0xFFD4D4D4), + isPassword: true), + const SizedBox(height: 12), + _credLine( + '# Changez le mot de passe à la 1ère connexion', + color: const Color(0xFF6A9955), + isComment: true), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // ── Boutons partage ────────────────────────────────── + const Text( + 'PARTAGER LES IDENTIFIANTS', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textTertiary, + letterSpacing: 1), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _shareButton( + icon: Icons.sms_rounded, + label: 'SMS', + color: UnionFlowColors.unionGreen, + onTap: _shareViaSms, + ), ), - child: const Text('Créer le membre'), + const SizedBox(width: 10), + Expanded( + child: _shareButton( + icon: Icons.email_rounded, + label: 'Email', + color: UnionFlowColors.info, + onTap: _shareViaEmail, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _shareButton( + icon: Icons.share_rounded, + label: 'Partager', + color: UnionFlowColors.amber, + onTap: _shareViaSms, // opens share sheet + ), + ), + ], + ), + const SizedBox(height: 14), + + // ── Note prochaine étape ───────────────────────────── + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: UnionFlowColors.warningPale, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.warningLight), ), - ], + child: const Row( + children: [ + Icon(Icons.info_outline_rounded, + size: 14, color: UnionFlowColors.warning), + SizedBox(width: 8), + Expanded( + child: Text( + 'Pensez à valider l\'adhésion du membre pour activer son accès complet.', + style: TextStyle( + fontSize: 11, + color: UnionFlowColors.textSecondary, + height: 1.4), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + + // ── Action ─────────────────────────────────────────────── + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + child: const Text('Compris', + style: TextStyle(fontWeight: FontWeight.w600)), + ), ), + ], + ), + ), + ), + ); + } + + Widget _credLine( + String label, { + String? value, + Color? color, + Color? labelColor, + Color? valueColor, + bool isComment = false, + bool isPassword = false, + }) { + if (isComment) { + return Text( + label, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: color ?? const Color(0xFF6A9955), + height: 1.4), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: labelColor ?? const Color(0xFF9CDCFE), + fontWeight: FontWeight.w600), + ), + const Text(' ', + style: TextStyle(fontFamily: 'monospace', fontSize: 13)), + Expanded( + child: SelectableText( + value ?? '', + style: TextStyle( + fontFamily: 'monospace', + fontSize: isPassword ? 15 : 13, + color: valueColor ?? const Color(0xFFD4D4D4), + fontWeight: + isPassword ? FontWeight.w700 : FontWeight.normal, + letterSpacing: isPassword ? 1.5 : 0, ), + ), + ), + ], + ); + } + + Widget _shareButton({ + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Column( + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(height: 4), + Text(label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color)), ], ), ), ); } +} - Widget _buildSectionTitle(String title) { - return Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), +// ─── Bottom sheet ───────────────────────────────────────────────────────────── + +class AddMemberSheet extends StatefulWidget { + final AuthState authState; + final void Function(MembreCompletModel) onCreated; + + const AddMemberSheet({ + super.key, + required this.authState, + required this.onCreated, + }); + + @override + State createState() => _AddMemberSheetState(); +} + +class _AddMemberSheetState extends State { + final _formKey = GlobalKey(); + + // Requis + final _prenomCtrl = TextEditingController(); + final _nomCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + DateTime? _dateNaissance; + bool _dateMissing = false; + + // Optionnels + final _telephoneCtrl = TextEditingController(); + final _telephoneWaveCtrl = TextEditingController(); + final _professionCtrl = TextEditingController(); + final _nationaliteCtrl = TextEditingController(); + final _numeroIdentiteCtrl = TextEditingController(); + String? _statutMatrimonial; + String? _typeIdentite; + + bool _showOptional = false; + bool _isSubmitting = false; + String? _errorMessage; + + // Sélection organisation (superadmin uniquement) + String? _selectedOrganisationId; + String? _selectedOrganisationNom; + List> _organisations = []; + bool _loadingOrgs = false; + + bool get _isSuperAdmin { + final auth = widget.authState; + return auth is AuthAuthenticated && + auth.user.primaryRole == UserRole.superAdmin; + } + + @override + void initState() { + super.initState(); + if (_isSuperAdmin) _loadOrganisations(); + } + + Future _loadOrganisations() async { + setState(() => _loadingOrgs = true); + try { + final repo = GetIt.instance(); + final orgs = await repo.getOrganizations(page: 0, size: 100); + if (mounted) { + setState(() { + _organisations = orgs + .where((o) => o.id != null && o.id!.isNotEmpty) + .map((o) => {'id': o.id!, 'nom': o.nomAffichage}) + .toList(); + }); + } + } catch (e) { + // Non bloquant : le sélecteur affichera un message d'erreur + } finally { + if (mounted) setState(() => _loadingOrgs = false); + } + } + + static const _statutOptions = [ + 'CELIBATAIRE', + 'MARIE', + 'DIVORCE', + 'VEUF', + ]; + static const _statutLabels = { + 'CELIBATAIRE': 'Célibataire', + 'MARIE': 'Marié(e)', + 'DIVORCE': 'Divorcé(e)', + 'VEUF': 'Veuf / Veuve', + }; + + static const _identiteOptions = [ + 'CNI', + 'PASSEPORT', + 'PERMIS_CONDUIRE', + 'TITRE_SEJOUR', + ]; + static const _identiteLabels = { + 'CNI': "Carte Nationale d'Identité", + 'PASSEPORT': 'Passeport', + 'PERMIS_CONDUIRE': 'Permis de conduire', + 'TITRE_SEJOUR': 'Titre de séjour', + }; + + @override + void dispose() { + _prenomCtrl.dispose(); + _nomCtrl.dispose(); + _emailCtrl.dispose(); + _telephoneCtrl.dispose(); + _telephoneWaveCtrl.dispose(); + _professionCtrl.dispose(); + _nationaliteCtrl.dispose(); + _numeroIdentiteCtrl.dispose(); + super.dispose(); + } + + String? _resolveOrganisationId() { + // Superadmin : utilise l'org sélectionnée dans le sélecteur + if (_isSuperAdmin) return _selectedOrganisationId; + + final auth = widget.authState; + if (auth is AuthAuthenticated) { + return auth.user.organizationContexts.isNotEmpty + ? auth.user.organizationContexts.first.organizationId + : null; + } + if (auth is AuthPendingOnboarding) return auth.organisationId; + return null; + } + + void _submit() { + final formValid = _formKey.currentState!.validate(); + final dateValid = _dateNaissance != null; + final orgValid = !_isSuperAdmin || _selectedOrganisationId != null; + + setState(() { + _dateMissing = !dateValid; + if (!orgValid) _errorMessage = 'Veuillez sélectionner une organisation.'; + }); + + if (!formValid || !dateValid || !orgValid) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + final membre = MembreCompletModel( + prenom: _prenomCtrl.text.trim(), + nom: _nomCtrl.text.trim(), + email: _emailCtrl.text.trim().toLowerCase(), + dateNaissance: _dateNaissance, + telephone: _val(_telephoneCtrl), + telephoneWave: _val(_telephoneWaveCtrl), + profession: _val(_professionCtrl), + nationalite: _val(_nationaliteCtrl), + statutMatrimonial: _statutMatrimonial, + typeIdentite: _typeIdentite, + numeroIdentite: _val(_numeroIdentiteCtrl), + organisationId: _resolveOrganisationId(), + statut: StatutMembre.actif, + ); + + context.read().add(CreateMembre(membre)); + } + + String? _val(TextEditingController ctrl) { + final v = ctrl.text.trim(); + return v.isNotEmpty ? v : null; + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (ctx, state) { + if (state is MembresLoading) { + if (mounted) setState(() => _isSubmitting = true); + } else if (state is MembreCreated) { + // Fermer la sheet puis afficher le dialog credentials (gère mot de passe null ou non) + Navigator.pop(ctx); + widget.onCreated(state.membre); + } else if (state is MembresError || state is MembresValidationError) { + final msg = state is MembresValidationError + ? state.validationErrors.values.join(' · ') + : (state as MembresError).message; + if (mounted) { + setState(() { + _isSubmitting = false; + _errorMessage = msg; + }); + } + } else { + if (mounted) setState(() => _isSubmitting = false); + } + }, + child: DraggableScrollableSheet( + initialChildSize: 0.92, + maxChildSize: 0.97, + minChildSize: 0.5, + builder: (_, scrollCtrl) => Container( + decoration: const BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + _buildHandle(), + _buildHeader(), + const Divider(height: 1, color: UnionFlowColors.border), + if (_errorMessage != null) _buildErrorBanner(), + Expanded(child: _buildForm(scrollCtrl)), + _buildFooter(), + ], + ), + ), ), ); } - String _getGenreLabel(Genre genre) { - switch (genre) { - case Genre.homme: - return 'Homme'; - case Genre.femme: - return 'Femme'; - case Genre.autre: - return 'Autre'; - } + // ── Handle ───────────────────────────────────────────────────────────────── + + Widget _buildHandle() => Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 6), + width: 36, + height: 4, + decoration: BoxDecoration( + color: UnionFlowColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + + // ── Header ───────────────────────────────────────────────────────────────── + + Widget _buildHeader() => Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 8, 14), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: UnionFlowColors.unionGreenPale, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.person_add_rounded, + color: UnionFlowColors.unionGreen, size: 20), + ), + const SizedBox(width: 12), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Nouveau membre', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary), + ), + Text( + 'Compte Keycloak provisionné automatiquement', + style: TextStyle( + fontSize: 11, color: UnionFlowColors.textSecondary), + ), + ], + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close_rounded, + size: 20, color: UnionFlowColors.textSecondary), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + + // ── Bannière erreur ──────────────────────────────────────────────────────── + + Widget _buildErrorBanner() => Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: UnionFlowColors.errorPale, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.errorLight), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + size: 16, color: UnionFlowColors.error), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle( + fontSize: 12, color: UnionFlowColors.error), + ), + ), + ], + ), + ); + + // ── Formulaire ───────────────────────────────────────────────────────────── + + Widget _buildOrgPicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Organisation', required: true), + const SizedBox(height: 10), + if (_loadingOrgs) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (_organisations.isEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UnionFlowColors.warningPale, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.warning.withOpacity(0.4)), + ), + child: const Text( + 'Aucune organisation disponible. Créez d\'abord une organisation.', + style: TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary), + ), + ) + else + DropdownButtonFormField( + value: _selectedOrganisationId, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.business_outlined, size: 18, + color: UnionFlowColors.textSecondary), + hintText: 'Sélectionner une organisation', + hintStyle: const TextStyle(fontSize: 13, color: UnionFlowColors.textTertiary), + contentPadding: const EdgeInsets.symmetric(vertical: 13, horizontal: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5), + ), + filled: true, + fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3), + ), + isExpanded: true, + items: _organisations + .map((org) => DropdownMenuItem( + value: org['id'], + child: Text( + org['nom']!, + style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary), + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (val) => setState(() { + _selectedOrganisationId = val; + _selectedOrganisationNom = _organisations + .firstWhere((o) => o['id'] == val, orElse: () => {})['nom']; + _errorMessage = null; + }), + validator: (v) => v == null ? 'Obligatoire' : null, + ), + const SizedBox(height: 20), + ], + ); } - Future _selectDate(BuildContext context) async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)), - firstDate: DateTime(1900), - lastDate: DateTime.now(), + Widget _buildForm(ScrollController scrollCtrl) => SingleChildScrollView( + controller: scrollCtrl, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sélecteur organisation — superadmin uniquement + if (_isSuperAdmin) _buildOrgPicker(), + + // Identité — requis + _sectionLabel('Identité', required: true), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: _field(_prenomCtrl, 'Prénom', + required: true, icon: Icons.badge_outlined), + ), + const SizedBox(width: 10), + Expanded( + child: _field(_nomCtrl, 'Nom', required: true), + ), + ], + ), + const SizedBox(height: 10), + + _field( + _emailCtrl, + 'Adresse email', + required: true, + icon: Icons.alternate_email_rounded, + keyboard: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Obligatoire'; + if (!RegExp(r'^[\w.+\-]+@[\w\-]+\.[a-z]{2,}$') + .hasMatch(v.trim())) { + return 'Format invalide'; + } + return null; + }, + ), + const SizedBox(height: 10), + + _datePicker(), + + const SizedBox(height: 20), + + // Section optionnelle — accordéon + _optionalToggle(), + + if (_showOptional) ...[ + const SizedBox(height: 16), + + _sectionLabel('Contact'), + const SizedBox(height: 10), + + _field(_telephoneCtrl, 'Téléphone', + icon: Icons.phone_outlined, + keyboard: TextInputType.phone, + hint: 'ex: +22507XXXXXXXX', + maxLength: 20, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) { + return 'Format E.164 requis (ex: +22507XXXXXXXX)'; + } + return null; + }), + const SizedBox(height: 10), + + _field(_telephoneWaveCtrl, 'Numéro Wave', + icon: Icons.waves_rounded, + keyboard: TextInputType.phone, + hint: 'ex: +22507XXXXXXXX', + maxLength: 20, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) { + return 'Format E.164 requis (ex: +22507XXXXXXXX)'; + } + return null; + }), + + const SizedBox(height: 18), + _sectionLabel('Profil'), + const SizedBox(height: 10), + + _field(_professionCtrl, 'Profession', + icon: Icons.work_outline_rounded, maxLength: 100), + const SizedBox(height: 10), + + _field(_nationaliteCtrl, 'Nationalité', + icon: Icons.flag_outlined, maxLength: 100), + const SizedBox(height: 10), + + _dropdown( + label: 'Statut matrimonial', + value: _statutMatrimonial, + items: _statutOptions, + labels: _statutLabels, + icon: Icons.favorite_border_rounded, + onChanged: (v) => setState(() => _statutMatrimonial = v), + ), + + const SizedBox(height: 18), + _sectionLabel("Pièce d'identité"), + const SizedBox(height: 10), + + _dropdown( + label: 'Type de pièce', + value: _typeIdentite, + items: _identiteOptions, + labels: _identiteLabels, + icon: Icons.credit_card_rounded, + onChanged: (v) => setState(() => _typeIdentite = v), + ), + const SizedBox(height: 10), + + _field(_numeroIdentiteCtrl, 'Numéro', + icon: Icons.numbers_rounded, maxLength: 100), + ], + + const SizedBox(height: 12), + ], + ), + ), + ); + + // ── Pied de page ─────────────────────────────────────────────────────────── + + Widget _buildFooter() => Container( + padding: EdgeInsets.fromLTRB( + 20, 12, 20, 16 + MediaQuery.of(context).viewInsets.bottom), + decoration: const BoxDecoration( + color: UnionFlowColors.surface, + border: Border(top: BorderSide(color: UnionFlowColors.border)), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: UnionFlowColors.border), + foregroundColor: UnionFlowColors.textSecondary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + child: const Text('Annuler'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton( + onPressed: _isSubmitting ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_add_rounded, size: 16), + SizedBox(width: 8), + Text('Créer le membre', + style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ], + ), + ); + + // ── Composants internes ──────────────────────────────────────────────────── + + Widget _sectionLabel(String label, {bool required = false}) => Row( + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textTertiary, + letterSpacing: 1, + ), + ), + if (required) ...[ + const SizedBox(width: 4), + const Text('*', + style: TextStyle( + color: UnionFlowColors.error, + fontSize: 12, + fontWeight: FontWeight.w700)), + ], + ], + ); + + Widget _optionalToggle() => InkWell( + onTap: () => setState(() => _showOptional = !_showOptional), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: UnionFlowColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.border), + ), + child: Row( + children: [ + const Icon(Icons.tune_rounded, + size: 16, color: UnionFlowColors.textSecondary), + const SizedBox(width: 8), + const Text( + 'Informations complémentaires', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: UnionFlowColors.textSecondary), + ), + const Spacer(), + Text( + _showOptional ? 'Réduire' : 'Développer', + style: const TextStyle( + fontSize: 11, color: UnionFlowColors.unionGreen), + ), + Icon( + _showOptional ? Icons.expand_less : Icons.expand_more, + size: 18, + color: UnionFlowColors.unionGreen, + ), + ], + ), + ), + ); + + InputDecoration _inputDeco(String label, + {IconData? icon, String? hint, int? maxLength, bool required = false}) { + return InputDecoration( + labelText: required ? '$label *' : label, + hintText: hint, + counterText: '', + prefixIcon: icon != null + ? Icon(icon, size: 18, color: UnionFlowColors.textTertiary) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: UnionFlowColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: UnionFlowColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: UnionFlowColors.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: UnionFlowColors.error, width: 1.5), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + filled: true, + fillColor: UnionFlowColors.surface, + labelStyle: + const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), ); - if (picked != null && picked != _dateNaissance) { + } + + Widget _field( + TextEditingController ctrl, + String label, { + bool required = false, + IconData? icon, + TextInputType? keyboard, + String? Function(String?)? validator, + String? hint, + int? maxLength, + }) { + return TextFormField( + controller: ctrl, + keyboardType: keyboard, + maxLength: maxLength, + style: + const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary), + decoration: _inputDeco(label, + icon: icon, hint: hint, maxLength: maxLength, required: required), + validator: validator ?? + (required + ? (v) => + (v == null || v.trim().isEmpty) ? 'Champ obligatoire' : null + : null), + ); + } + + Widget _dropdown({ + required String label, + required String? value, + required List items, + required Map labels, + required IconData icon, + required void Function(String?) onChanged, + }) { + return DropdownButtonFormField( + value: value, + isExpanded: true, + decoration: _inputDeco(label, icon: icon), + style: + const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary), + items: items + .map((k) => DropdownMenuItem( + value: k, + child: Text(labels[k] ?? k, + style: const TextStyle(fontSize: 14)), + )) + .toList(), + onChanged: onChanged, + ); + } + + Widget _datePicker() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(10), + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Date de naissance *', + prefixIcon: const Icon(Icons.calendar_today_rounded, + size: 18, color: UnionFlowColors.textTertiary), + suffixIcon: const Icon(Icons.chevron_right_rounded, + size: 18, color: UnionFlowColors.textTertiary), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: _dateMissing + ? UnionFlowColors.error + : UnionFlowColors.border, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: _dateMissing + ? UnionFlowColors.error + : _dateNaissance != null + ? UnionFlowColors.unionGreen + : UnionFlowColors.border, + ), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + filled: true, + fillColor: UnionFlowColors.surface, + labelStyle: const TextStyle( + fontSize: 13, color: UnionFlowColors.textSecondary), + ), + child: Text( + _dateNaissance != null + ? DateFormat('dd MMMM yyyy', 'fr').format(_dateNaissance!) + : 'Sélectionner une date', + style: TextStyle( + fontSize: 14, + color: _dateNaissance != null + ? UnionFlowColors.textPrimary + : UnionFlowColors.textTertiary, + ), + ), + ), + ), + if (_dateMissing) + const Padding( + padding: EdgeInsets.only(top: 4, left: 14), + child: Text( + 'La date de naissance est obligatoire', + style: TextStyle(fontSize: 11, color: UnionFlowColors.error), + ), + ), + ], + ); + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _dateNaissance ?? DateTime(1990), + firstDate: DateTime(1900), + lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: Theme.of(ctx).colorScheme.copyWith( + primary: UnionFlowColors.unionGreen, + ), + ), + child: child!, + ), + ); + if (picked != null) { setState(() { _dateNaissance = picked; + _dateMissing = false; }); } } - - void _submitForm() { - if (_formKey.currentState!.validate()) { - // Créer le modèle de membre - final membre = MembreCompletModel( - nom: _nomController.text, - prenom: _prenomController.text, - email: _emailController.text, - telephone: _telephoneController.text.isNotEmpty ? _telephoneController.text : null, - dateNaissance: _dateNaissance, - genre: _selectedGenre, - adresse: _adresseController.text.isNotEmpty ? _adresseController.text : null, - ville: _villeController.text.isNotEmpty ? _villeController.text : null, - codePostal: _codePostalController.text.isNotEmpty ? _codePostalController.text : null, - region: _regionController.text.isNotEmpty ? _regionController.text : null, - pays: _paysController.text.isNotEmpty ? _paysController.text : null, - profession: _professionController.text.isNotEmpty ? _professionController.text : null, - nationalite: _nationaliteController.text.isNotEmpty ? _nationaliteController.text : null, - statut: _selectedStatut, - ); - - // Envoyer l'événement au BLoC - context.read().add(CreateMembre(membre)); - - // Fermer le dialogue - Navigator.pop(context); - - // Afficher un message de succès - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Membre créé avec succès'), - backgroundColor: Colors.green, - ), - ); - } - } } - diff --git a/lib/features/onboarding/bloc/onboarding_bloc.dart b/lib/features/onboarding/bloc/onboarding_bloc.dart index ff2896d..07dd9dc 100644 --- a/lib/features/onboarding/bloc/onboarding_bloc.dart +++ b/lib/features/onboarding/bloc/onboarding_bloc.dart @@ -18,9 +18,16 @@ abstract class OnboardingEvent extends Equatable { class OnboardingStarted extends OnboardingEvent { final String? existingSouscriptionId; final String initialState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION - const OnboardingStarted({required this.initialState, this.existingSouscriptionId}); + final String? typeOrganisation; + final String? organisationId; + const OnboardingStarted({ + required this.initialState, + this.existingSouscriptionId, + this.typeOrganisation, + this.organisationId, + }); @override - List get props => [initialState, existingSouscriptionId]; + List get props => [initialState, existingSouscriptionId, typeOrganisation, organisationId]; } /// L'utilisateur a sélectionné une formule et une plage @@ -51,6 +58,11 @@ class OnboardingDemandeConfirmee extends OnboardingEvent { const OnboardingDemandeConfirmee(); } +/// Ouvre l'écran de choix du moyen de paiement (depuis le récapitulatif) +class OnboardingChoixPaiementOuvert extends OnboardingEvent { + const OnboardingChoixPaiementOuvert(); +} + /// Initie le paiement Wave class OnboardingPaiementInitie extends OnboardingEvent { const OnboardingPaiementInitie(); @@ -109,6 +121,14 @@ class OnboardingStepSummary extends OnboardingState { List get props => [souscription]; } +/// Étape 3b : choix du moyen de paiement (Wave, Orange Money, etc.) +class OnboardingStepChoixPaiement extends OnboardingState { + final SouscriptionStatusModel souscription; + const OnboardingStepChoixPaiement(this.souscription); + @override + List get props => [souscription]; +} + /// Étape 4 : paiement Wave — URL à ouvrir dans le navigateur class OnboardingStepPaiement extends OnboardingState { final SouscriptionStatusModel souscription; @@ -118,6 +138,9 @@ class OnboardingStepPaiement extends OnboardingState { List get props => [souscription, waveLaunchUrl]; } +/// Paiement confirmé — déclenche un re-check du statut du compte +class OnboardingPaiementConfirme extends OnboardingState {} + /// Étape 5 : en attente de validation SuperAdmin class OnboardingStepAttente extends OnboardingState { final SouscriptionStatusModel? souscription; @@ -145,7 +168,7 @@ class OnboardingBloc extends Bloc { String? _codeFormule; String? _plage; String? _typePeriode; - String? _typeOrganisation; + String _typeOrganisation = ''; String? _organisationId; SouscriptionStatusModel? _souscription; @@ -154,12 +177,19 @@ class OnboardingBloc extends Bloc { on(_onFormuleSelected); on(_onPeriodeSelected); on(_onDemandeConfirmee); + on(_onChoixPaiementOuvert); on(_onPaiementInitie); on(_onRetourDepuisWave); } Future _onStarted(OnboardingStarted event, Emitter emit) async { emit(OnboardingLoading()); + if (event.typeOrganisation != null && event.typeOrganisation!.isNotEmpty) { + _typeOrganisation = event.typeOrganisation!; + } + if (event.organisationId != null && event.organisationId!.isNotEmpty) { + _organisationId = event.organisationId; + } try { _formules = await _datasource.getFormules(); @@ -189,6 +219,7 @@ class OnboardingBloc extends Bloc { } case 'AWAITING_VALIDATION': + case 'VALIDATED': // Paiement confirmé mais activation compte non encore effective final sosc = await _datasource.getMaSouscription(); _souscription = sosc; emit(OnboardingStepAttente(souscription: sosc)); @@ -218,14 +249,23 @@ class OnboardingBloc extends Bloc { void _onPeriodeSelected(OnboardingPeriodeSelected event, Emitter emit) { _typePeriode = event.typePeriode; - _typeOrganisation = event.typeOrganisation; + // typeOrganisation already set from OnboardingStarted; override only if event provides one + if (event.typeOrganisation.isNotEmpty) { + _typeOrganisation = event.typeOrganisation; + } _organisationId = event.organisationId; } + void _onChoixPaiementOuvert(OnboardingChoixPaiementOuvert event, Emitter emit) { + if (_souscription != null) { + emit(OnboardingStepChoixPaiement(_souscription!)); + } + } + Future _onDemandeConfirmee( OnboardingDemandeConfirmee event, Emitter emit) async { if (_codeFormule == null || _plage == null || _typePeriode == null || - _typeOrganisation == null || _organisationId == null) { + _organisationId == null) { emit(const OnboardingError('Données manquantes. Recommencez depuis le début.')); return; } @@ -235,7 +275,7 @@ class OnboardingBloc extends Bloc { typeFormule: _codeFormule!, plageMembres: _plage!, typePeriode: _typePeriode!, - typeOrganisation: _typeOrganisation!, + typeOrganisation: _typeOrganisation.isNotEmpty ? _typeOrganisation : null, organisationId: _organisationId!, ); if (sosc != null) { @@ -281,12 +321,11 @@ class OnboardingBloc extends Bloc { if (souscId != null) { await _datasource.confirmerPaiement(souscId); } - final sosc = await _datasource.getMaSouscription(); - _souscription = sosc; - emit(OnboardingStepAttente(souscription: sosc)); + // Émettre OnboardingPaiementConfirme pour déclencher re-check du compte + // Si le backend auto-active le compte, AuthStatusChecked redirigera vers dashboard + emit(OnboardingPaiementConfirme()); } catch (e) { - // En cas d'erreur, on affiche quand même l'écran d'attente - emit(OnboardingStepAttente(souscription: _souscription)); + emit(OnboardingPaiementConfirme()); } } } diff --git a/lib/features/onboarding/data/datasources/souscription_datasource.dart b/lib/features/onboarding/data/datasources/souscription_datasource.dart index e240e38..2a509e2 100644 --- a/lib/features/onboarding/data/datasources/souscription_datasource.dart +++ b/lib/features/onboarding/data/datasources/souscription_datasource.dart @@ -58,26 +58,32 @@ class SouscriptionDatasource { required String typeFormule, required String plageMembres, required String typePeriode, - required String typeOrganisation, + String? typeOrganisation, required String organisationId, }) async { try { final opts = await _authOptions(); + final body = { + 'typeFormule': typeFormule, + 'plageMembres': plageMembres, + 'typePeriode': typePeriode, + 'organisationId': organisationId, + }; + if (typeOrganisation != null && typeOrganisation.isNotEmpty) { + body['typeOrganisation'] = typeOrganisation; + } final response = await _dio.post( '$_base/api/souscriptions/demande', - data: { - 'typeFormule': typeFormule, - 'plageMembres': plageMembres, - 'typePeriode': typePeriode, - 'typeOrganisation': typeOrganisation, - 'organisationId': organisationId, - }, + data: body, options: opts, ); if ((response.statusCode == 200 || response.statusCode == 201) && response.data is Map) { return SouscriptionStatusModel.fromJson(response.data as Map); } - AppLogger.warning('SouscriptionDatasource.creerDemande: HTTP ${response.statusCode}'); + final errMsg = response.data is Map + ? (response.data as Map)['message'] ?? response.data.toString() + : response.data?.toString() ?? '(vide)'; + AppLogger.warning('SouscriptionDatasource.creerDemande: HTTP ${response.statusCode} — erreur="$errMsg" — sentBody=$body'); } catch (e) { AppLogger.error('SouscriptionDatasource.creerDemande: $e'); } @@ -106,7 +112,8 @@ class SouscriptionDatasource { try { final opts = await _authOptions(); final response = await _dio.post( - '$_base/api/souscriptions/$souscriptionId/confirmer-paiement', + '$_base/api/souscriptions/confirmer-paiement', + queryParameters: {'id': souscriptionId}, options: opts, ); return response.statusCode == 200; diff --git a/lib/features/onboarding/data/models/souscription_status_model.dart b/lib/features/onboarding/data/models/souscription_status_model.dart index e786021..d00622d 100644 --- a/lib/features/onboarding/data/models/souscription_status_model.dart +++ b/lib/features/onboarding/data/models/souscription_status_model.dart @@ -14,6 +14,8 @@ class SouscriptionStatusModel { final String? waveLaunchUrl; final String organisationId; final String? organisationNom; + final DateTime? dateDebut; + final DateTime? dateFin; const SouscriptionStatusModel({ required this.souscriptionId, @@ -30,6 +32,8 @@ class SouscriptionStatusModel { this.waveLaunchUrl, required this.organisationId, this.organisationNom, + this.dateDebut, + this.dateFin, }); factory SouscriptionStatusModel.fromJson(Map json) { @@ -48,6 +52,12 @@ class SouscriptionStatusModel { waveLaunchUrl: json['waveLaunchUrl'] as String?, organisationId: json['organisationId'] as String, organisationNom: json['organisationNom'] as String?, + dateDebut: json['dateDebut'] != null + ? DateTime.tryParse(json['dateDebut'] as String) + : null, + dateFin: json['dateFin'] != null + ? DateTime.tryParse(json['dateFin'] as String) + : null, ); } } diff --git a/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart b/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart index 7a01e8c..00519a8 100644 --- a/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart +++ b/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../data/models/souscription_status_model.dart'; import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -/// Étape 5 — En attente de validation SuperAdmin +/// Page de secours — affichée si l'auto-activation échoue après paiement. +/// Normalement jamais vue : confirmerPaiement() active le compte côté backend. class AwaitingValidationPage extends StatefulWidget { final SouscriptionStatusModel? souscription; @@ -19,6 +21,7 @@ class _AwaitingValidationPageState extends State late AnimationController _pulseController; late Animation _pulseAnimation; Timer? _refreshTimer; + int _checkCount = 0; @override void initState() { @@ -27,13 +30,14 @@ class _AwaitingValidationPageState extends State vsync: this, duration: const Duration(seconds: 2), )..repeat(reverse: true); - _pulseAnimation = Tween(begin: 0.85, end: 1.0).animate( + _pulseAnimation = Tween(begin: 0.88, end: 1.0).animate( CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), ); - // Vérification périodique toutes les 30 secondes (re-check le statut) - _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { + // Vérification périodique toutes les 15 secondes + _refreshTimer = Timer.periodic(const Duration(seconds: 15), (_) { if (mounted) { + setState(() => _checkCount++); context.read().add(const AuthStatusChecked()); } }); @@ -49,71 +53,169 @@ class _AwaitingValidationPageState extends State @override Widget build(BuildContext context) { final sosc = widget.souscription; + return Scaffold( + backgroundColor: UnionFlowColors.background, body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - // Animation d'attente + const SizedBox(height: 32), + + // Animation pulsante ScaleTransition( scale: _pulseAnimation, child: Container( - width: 120, - height: 120, + width: 130, + height: 130, decoration: BoxDecoration( - color: const Color(0xFFFFF3E0), shape: BoxShape.circle, - border: Border.all(color: const Color(0xFFF57C00), width: 3), + color: UnionFlowColors.goldPale, + border: Border.all( + color: UnionFlowColors.gold.withOpacity(0.5), width: 3), + boxShadow: UnionFlowColors.goldGlowShadow, ), child: const Icon( Icons.hourglass_top_rounded, - size: 60, - color: Color(0xFFF57C00), + size: 64, + color: UnionFlowColors.gold, ), ), ), const SizedBox(height: 32), const Text( - 'Demande soumise !', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + 'Paiement reçu !', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: UnionFlowColors.textPrimary, + ), textAlign: TextAlign.center, ), - const SizedBox(height: 12), + const SizedBox(height: 10), const Text( - 'Votre souscription est en cours de vérification par notre équipe. ' - 'Vous recevrez une notification dès que votre compte sera activé.', - style: TextStyle(fontSize: 15, color: Colors.grey, height: 1.5), + 'Votre paiement a bien été reçu. Votre compte\nest en cours d\'activation.', + style: TextStyle( + fontSize: 15, + color: UnionFlowColors.textSecondary, + height: 1.5, + ), textAlign: TextAlign.center, ), - - if (sosc != null) ...[ - const SizedBox(height: 32), - _SummaryCard(souscription: sosc), - ], - const SizedBox(height: 32), - const Text( - 'Cette vérification prend généralement 24 à 48 heures ouvrables.', - style: TextStyle(fontSize: 13, color: Colors.grey), - textAlign: TextAlign.center, - ), + + // Souscription recap card + if (sosc != null) _RecapCard(souscription: sosc), + const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: () => - context.read().add(const AuthStatusChecked()), - icon: const Icon(Icons.refresh), - label: const Text('Vérifier l\'état de mon compte'), + // État de vérification + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(14), + boxShadow: UnionFlowColors.softShadow, + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: UnionFlowColors.unionGreen, + value: _checkCount > 0 ? null : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Vérification automatique en cours', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: UnionFlowColors.textPrimary, + ), + ), + Text( + 'Vérifié $_checkCount fois · prochaine dans 15s', + style: const TextStyle( + fontSize: 11, + color: UnionFlowColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), ), + const SizedBox(height: 20), + // Note d'information + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: UnionFlowColors.infoPale, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: UnionFlowColors.info.withOpacity(0.2)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, + color: UnionFlowColors.info, size: 18), + SizedBox(width: 10), + Expanded( + child: Text( + 'L\'activation est généralement immédiate. Si votre compte n\'est pas activé dans les 5 minutes, contactez notre support.', + style: TextStyle( + fontSize: 12, + color: UnionFlowColors.info, + height: 1.45), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => + context.read().add(const AuthStatusChecked()), + icon: const Icon(Icons.refresh_rounded), + label: const Text( + 'Vérifier maintenant', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + shadowColor: UnionFlowColors.unionGreen.withOpacity(0.3), + elevation: 2, + ), + ), + ), const SizedBox(height: 12), TextButton( onPressed: () => context.read().add(const AuthLogoutRequested()), - child: const Text('Se déconnecter'), + child: const Text( + 'Se déconnecter', + style: TextStyle(color: UnionFlowColors.textSecondary), + ), ), ], ), @@ -123,26 +225,49 @@ class _AwaitingValidationPageState extends State } } -class _SummaryCard extends StatelessWidget { +class _RecapCard extends StatelessWidget { final SouscriptionStatusModel souscription; - const _SummaryCard({required this.souscription}); + const _RecapCard({required this.souscription}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[200]!), + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(14), + boxShadow: UnionFlowColors.softShadow, + border: Border.all(color: UnionFlowColors.border), ), child: Column( children: [ - _Row('Organisation', souscription.organisationNom ?? '—'), - _Row('Formule', souscription.typeFormule), - _Row('Période', souscription.typePeriode), - if (souscription.montantTotal != null) - _Row('Montant payé', '${souscription.montantTotal!.toStringAsFixed(0)} FCFA'), + _Row( + icon: Icons.business_rounded, + label: 'Organisation', + value: souscription.organisationNom ?? '—', + ), + const Divider(height: 16, color: UnionFlowColors.border), + _Row( + icon: Icons.workspace_premium_rounded, + label: 'Formule', + value: souscription.typeFormule, + ), + const SizedBox(height: 8), + _Row( + icon: Icons.calendar_today_rounded, + label: 'Période', + value: souscription.typePeriode, + ), + if (souscription.montantTotal != null) ...[ + const SizedBox(height: 8), + _Row( + icon: Icons.payments_rounded, + label: 'Montant payé', + value: '${souscription.montantTotal!.toStringAsFixed(0)} FCFA', + valueColor: UnionFlowColors.unionGreen, + valueBold: true, + ), + ], ], ), ); @@ -150,22 +275,41 @@ class _SummaryCard extends StatelessWidget { } class _Row extends StatelessWidget { + final IconData icon; final String label; final String value; - const _Row(this.label, this.value); + final Color valueColor; + final bool valueBold; + + const _Row({ + required this.icon, + required this.label, + required this.value, + this.valueColor = UnionFlowColors.textPrimary, + this.valueBold = false, + }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: const TextStyle(color: Colors.grey, fontSize: 13)), - Text(value, - style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13)), - ], - ), + return Row( + children: [ + Icon(icon, size: 16, color: UnionFlowColors.textTertiary), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: UnionFlowColors.textSecondary, fontSize: 13), + ), + const Spacer(), + Text( + value, + style: TextStyle( + color: valueColor, + fontSize: 13, + fontWeight: valueBold ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], ); } } diff --git a/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart index b2adb6e..c0830b8 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../../../core/di/injection.dart'; +import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import 'plan_selection_page.dart'; import 'period_selection_page.dart'; import 'subscription_summary_page.dart'; +import 'payment_method_page.dart'; import 'wave_payment_page.dart'; import 'awaiting_validation_page.dart'; @@ -14,11 +17,13 @@ class OnboardingFlowPage extends StatelessWidget { final String onboardingState; final String? souscriptionId; final String organisationId; + final String? typeOrganisation; const OnboardingFlowPage({ super.key, required this.onboardingState, required this.organisationId, + this.typeOrganisation, this.souscriptionId, }); @@ -29,6 +34,8 @@ class OnboardingFlowPage extends StatelessWidget { ..add(OnboardingStarted( initialState: onboardingState, existingSouscriptionId: souscriptionId, + typeOrganisation: typeOrganisation, + organisationId: organisationId.isNotEmpty ? organisationId : null, )), child: const _OnboardingFlowView(), ); @@ -40,30 +47,76 @@ class _OnboardingFlowView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listener: (context, state) { + // Paiement confirmé → re-check du statut (auto-activation backend) + if (state is OnboardingPaiementConfirme) { + context.read().add(const AuthStatusChecked()); + } + }, builder: (context, state) { - if (state is OnboardingLoading || state is OnboardingInitial) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), + if (state is OnboardingLoading || state is OnboardingInitial || state is OnboardingPaiementConfirme) { + return Scaffold( + backgroundColor: UnionFlowColors.background, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + color: UnionFlowColors.unionGreen, + ), + const SizedBox(height: 16), + Text( + state is OnboardingPaiementConfirme + ? 'Activation de votre compte…' + : 'Chargement…', + style: const TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 15, + ), + ), + ], + ), + ), ); } if (state is OnboardingError) { return Scaffold( + backgroundColor: UnionFlowColors.background, body: Center( child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.error_outline, size: 64, color: Colors.red), - const SizedBox(height: 16), - Text(state.message, textAlign: TextAlign.center), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: UnionFlowColors.errorPale, + shape: BoxShape.circle, + ), + child: const Icon(Icons.error_outline, + size: 40, color: UnionFlowColors.error), + ), + const SizedBox(height: 20), + Text( + state.message, + textAlign: TextAlign.center, + style: const TextStyle( + color: UnionFlowColors.textPrimary, fontSize: 15), + ), const SizedBox(height: 24), ElevatedButton( onPressed: () => context.read().add( - OnboardingStarted(initialState: 'NO_SUBSCRIPTION'), + const OnboardingStarted( + initialState: 'NO_SUBSCRIPTION'), ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + ), child: const Text('Réessayer'), ), ], @@ -89,6 +142,10 @@ class _OnboardingFlowView extends StatelessWidget { return SubscriptionSummaryPage(souscription: state.souscription); } + if (state is OnboardingStepChoixPaiement) { + return PaymentMethodPage(souscription: state.souscription); + } + if (state is OnboardingStepPaiement) { return WavePaymentPage( souscription: state.souscription, @@ -104,7 +161,9 @@ class _OnboardingFlowView extends StatelessWidget { return _RejectedPage(commentaire: state.commentaire); } - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); }, ); } @@ -117,28 +176,93 @@ class _RejectedPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Center( + backgroundColor: UnionFlowColors.background, + body: SafeArea( child: Padding( padding: const EdgeInsets.all(32), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cancel_outlined, size: 72, color: Colors.red), - const SizedBox(height: 24), - const Text( - 'Souscription rejetée', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: UnionFlowColors.errorPale, + shape: BoxShape.circle, + ), + child: const Icon(Icons.cancel_outlined, + size: 52, color: UnionFlowColors.error), ), - if (commentaire != null) ...[ - const SizedBox(height: 12), - Text(commentaire!, textAlign: TextAlign.center), + const SizedBox(height: 28), + const Text( + 'Demande rejetée', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UnionFlowColors.textPrimary, + ), + ), + const SizedBox(height: 12), + const Text( + 'Votre demande de souscription a été refusée.', + textAlign: TextAlign.center, + style: TextStyle(color: UnionFlowColors.textSecondary), + ), + if (commentaire != null && commentaire!.isNotEmpty) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UnionFlowColors.errorPale, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: UnionFlowColors.error.withOpacity(0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.comment_outlined, + color: UnionFlowColors.error, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + commentaire!, + style: const TextStyle( + color: UnionFlowColors.textPrimary, + fontSize: 14, + height: 1.5), + ), + ), + ], + ), + ), ], - const SizedBox(height: 32), - ElevatedButton( - onPressed: () => context.read().add( - OnboardingStarted(initialState: 'NO_SUBSCRIPTION'), - ), - child: const Text('Nouvelle souscription'), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.read().add( + const OnboardingStarted( + initialState: 'NO_SUBSCRIPTION'), + ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Soumettre une nouvelle demande', + style: TextStyle(fontSize: 15)), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => + context.read().add(const AuthLogoutRequested()), + child: const Text('Se déconnecter', + style: + TextStyle(color: UnionFlowColors.textSecondary)), ), ], ), diff --git a/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart b/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart new file mode 100644 index 0000000..af42dc9 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; + +/// Header commun à toutes les étapes d'onboarding +class OnboardingStepHeader extends StatelessWidget { + final int step; + final int total; + final String title; + final String subtitle; + + const OnboardingStepHeader({ + super.key, + required this.step, + required this.total, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: UnionFlowColors.primaryGradient, + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: List.generate(total, (i) { + final done = i < step; + return Expanded( + child: Container( + height: 4, + margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), + decoration: BoxDecoration( + color: done + ? Colors.white + : Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + const SizedBox(height: 6), + Text( + 'Étape $step sur $total', + style: TextStyle( + color: Colors.white.withOpacity(0.75), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Titre de section avec icône +class OnboardingSectionTitle extends StatelessWidget { + final IconData icon; + final String title; + + const OnboardingSectionTitle({ + super.key, + required this.icon, + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: UnionFlowColors.unionGreen, size: 20), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + color: UnionFlowColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + ], + ); + } +} + +/// Barre de bouton principale en bas de page +class OnboardingBottomBar extends StatelessWidget { + final bool enabled; + final String label; + final VoidCallback onPressed; + + const OnboardingBottomBar({ + super.key, + required this.enabled, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: enabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + disabledBackgroundColor: UnionFlowColors.border, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: enabled ? 2 : 0, + shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/payment_method_page.dart b/lib/features/onboarding/presentation/pages/payment_method_page.dart new file mode 100644 index 0000000..dfa7e92 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/payment_method_page.dart @@ -0,0 +1,428 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../bloc/onboarding_bloc.dart'; +import '../../data/models/souscription_status_model.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; + +/// Écran de sélection du moyen de paiement +class PaymentMethodPage extends StatefulWidget { + final SouscriptionStatusModel souscription; + + const PaymentMethodPage({super.key, required this.souscription}); + + @override + State createState() => _PaymentMethodPageState(); +} + +class _PaymentMethodPageState extends State { + String? _selected; + + static const _methods = [ + _PayMethod( + id: 'WAVE', + name: 'Wave Mobile Money', + description: 'Paiement rapide via votre compte Wave', + logoAsset: 'assets/images/payment_methods/wave/logo.png', + color: Color(0xFF00B9F1), + available: true, + badge: 'Recommandé', + ), + _PayMethod( + id: 'ORANGE_MONEY', + name: 'Orange Money', + description: 'Paiement via Orange Money', + logoAsset: 'assets/images/payment_methods/orange_money/logo-black.png', + color: Color(0xFFFF6600), + available: false, + badge: 'Prochainement', + ), + ]; + + @override + Widget build(BuildContext context) { + final montant = widget.souscription.montantTotal ?? 0; + + return Scaffold( + backgroundColor: UnionFlowColors.background, + body: Column( + children: [ + // Header + Container( + decoration: const BoxDecoration( + gradient: UnionFlowColors.primaryGradient, + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, + color: Colors.white), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(height: 12), + const Text( + 'Moyen de paiement', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Choisissez comment régler votre souscription', + style: TextStyle( + color: Colors.white.withOpacity(0.8), fontSize: 13), + ), + ], + ), + ), + ), + ), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Montant rappel + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(14), + boxShadow: UnionFlowColors.softShadow, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: UnionFlowColors.goldGradient, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.receipt_rounded, + color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Montant total', + style: TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 12), + ), + Text( + '${_formatPrix(montant)} FCFA', + style: const TextStyle( + color: UnionFlowColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + if (widget.souscription.organisationNom != null) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text( + 'Organisation', + style: TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 11), + ), + Text( + widget.souscription.organisationNom!, + style: const TextStyle( + color: UnionFlowColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + + const Text( + 'Sélectionnez un moyen de paiement', + style: TextStyle( + color: UnionFlowColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 15, + ), + ), + const SizedBox(height: 12), + + ..._methods.map((m) => _MethodCard( + method: m, + selected: _selected == m.id, + onTap: m.available + ? () => setState(() => _selected = m.id) + : null, + )), + + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: UnionFlowColors.unionGreenPale, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + UnionFlowColors.unionGreen.withOpacity(0.25)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lock_rounded, + color: UnionFlowColors.unionGreen, size: 18), + SizedBox(width: 10), + Expanded( + child: Text( + 'Vos informations de paiement sont sécurisées et ne sont jamais stockées sur nos serveurs. La transaction est traitée directement par Wave.', + style: TextStyle( + fontSize: 12, + color: UnionFlowColors.unionGreen, + height: 1.4), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + bottomNavigationBar: Container( + padding: EdgeInsets.fromLTRB( + 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _selected == 'WAVE' + ? () => context + .read() + .add(const OnboardingPaiementInitie()) + : null, + icon: const Icon(Icons.open_in_new_rounded), + label: Text( + _selected == 'WAVE' + ? 'Payer avec Wave' + : 'Sélectionnez un moyen de paiement', + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00B9F1), + disabledBackgroundColor: UnionFlowColors.border, + foregroundColor: Colors.white, + disabledForegroundColor: UnionFlowColors.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + elevation: _selected == 'WAVE' ? 3 : 0, + shadowColor: const Color(0xFF00B9F1).withOpacity(0.4), + ), + ), + ), + ), + ); + } + + String _formatPrix(double prix) { + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + final s = prix.toStringAsFixed(0); + if (s.length > 3) { + return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; + } + return s; + } +} + +class _PayMethod { + final String id, name, description, logoAsset; + final Color color; + final bool available; + final String badge; + const _PayMethod({ + required this.id, + required this.name, + required this.description, + required this.logoAsset, + required this.color, + required this.available, + required this.badge, + }); +} + +class _MethodCard extends StatelessWidget { + final _PayMethod method; + final bool selected; + final VoidCallback? onTap; + + const _MethodCard({ + required this.method, + required this.selected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final disabled = onTap == null; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: disabled + ? UnionFlowColors.surfaceVariant + : selected + ? method.color.withOpacity(0.06) + : UnionFlowColors.surface, + border: Border.all( + color: selected ? method.color : UnionFlowColors.border, + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: disabled + ? [] + : selected + ? [ + BoxShadow( + color: method.color.withOpacity(0.15), + blurRadius: 16, + offset: const Offset(0, 6), + ) + ] + : UnionFlowColors.softShadow, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Logo image + Container( + width: 56, + height: 48, + decoration: BoxDecoration( + color: disabled + ? UnionFlowColors.border + : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.border), + ), + padding: const EdgeInsets.all(6), + child: Image.asset( + method.logoAsset, + fit: BoxFit.contain, + color: disabled ? UnionFlowColors.textTertiary : null, + colorBlendMode: disabled ? BlendMode.srcIn : null, + errorBuilder: (_, __, ___) => Icon( + Icons.account_balance_wallet_rounded, + color: disabled ? UnionFlowColors.textTertiary : method.color, + size: 24, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method.name, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: disabled + ? UnionFlowColors.textTertiary + : UnionFlowColors.textPrimary, + ), + ), + Text( + method.description, + style: TextStyle( + fontSize: 12, + color: disabled + ? UnionFlowColors.textTertiary + : UnionFlowColors.textSecondary, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: disabled + ? UnionFlowColors.border + : method.available + ? method.color.withOpacity(0.1) + : UnionFlowColors.surfaceVariant, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + method.badge, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: disabled + ? UnionFlowColors.textTertiary + : method.available + ? method.color + : UnionFlowColors.textSecondary, + ), + ), + ), + if (!disabled) ...[ + const SizedBox(height: 6), + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked, + color: selected ? method.color : UnionFlowColors.border, + size: 20, + ), + ], + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/period_selection_page.dart b/lib/features/onboarding/presentation/pages/period_selection_page.dart index af140d8..cf6cc16 100644 --- a/lib/features/onboarding/presentation/pages/period_selection_page.dart +++ b/lib/features/onboarding/presentation/pages/period_selection_page.dart @@ -3,8 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/formule_model.dart'; import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; +import 'onboarding_shared_widgets.dart'; -/// Étape 2 — Choix de la période de facturation et du type d'organisation +/// Étape 2 — Choix de la période de facturation +/// Le type d'organisation est récupéré automatiquement depuis le backend. class PeriodSelectionPage extends StatefulWidget { final String codeFormule; final String plage; @@ -23,35 +26,23 @@ class PeriodSelectionPage extends StatefulWidget { class _PeriodSelectionPageState extends State { String _selectedPeriode = 'MENSUEL'; - String _selectedTypeOrg = 'ASSOCIATION'; static const _periodes = [ - ('MENSUEL', 'Mensuel', 'Aucune remise', 1.00), - ('TRIMESTRIEL', 'Trimestriel', '5% de remise', 0.95), - ('SEMESTRIEL', 'Semestriel', '10% de remise', 0.90), - ('ANNUEL', 'Annuel', '20% de remise', 0.80), - ]; - - static const _typesOrg = [ - ('ASSOCIATION', 'Association / ONG locale', '×1.0'), - ('MUTUELLE', 'Mutuelle (santé, fonctionnaires…)', '×1.2'), - ('COOPERATIVE', 'Coopérative / Microfinance', '×1.3'), - ('FEDERATION', 'Fédération / Grande ONG', '×1.0 ou ×1.5 Premium'), + _Periode('MENSUEL', 'Mensuel', '1 mois', null, 1, 1.00), + _Periode('TRIMESTRIEL', 'Trimestriel', '3 mois', '–5%', 3, 0.95), + _Periode('SEMESTRIEL', 'Semestriel', '6 mois', '–10%', 6, 0.90), + _Periode('ANNUEL', 'Annuel', '12 mois', '–20%', 12, 0.80), ]; FormuleModel? get _formule => widget.formules .where((f) => f.code == widget.codeFormule && f.plage == widget.plage) .firstOrNull; - double get _prixEstime { + double _estimerPrix(String periodeCode) { final f = _formule; if (f == null) return 0; - final coefPeriode = - _periodes.firstWhere((p) => p.$1 == _selectedPeriode).$4; - final coefOrg = - _selectedTypeOrg == 'COOPERATIVE' ? 1.3 : _selectedTypeOrg == 'MUTUELLE' ? 1.2 : 1.0; - final nbMois = {'MENSUEL': 1, 'TRIMESTRIEL': 3, 'SEMESTRIEL': 6, 'ANNUEL': 12}[_selectedPeriode]!; - return f.prixMensuel * coefOrg * coefPeriode * nbMois; + final p = _periodes.firstWhere((x) => x.code == periodeCode); + return f.prixMensuel * p.coef * p.nbMois; } String get _organisationId { @@ -69,156 +60,363 @@ class _PeriodSelectionPageState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final prixSelected = _estimerPrix(_selectedPeriode); + return Scaffold( - appBar: AppBar( - title: const Text('Période & type d\'organisation'), - leading: BackButton( - onPressed: () => context.read().add( - OnboardingStarted(initialState: 'NO_SUBSCRIPTION'), - ), - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _StepIndicator(current: 2, total: 3), - const SizedBox(height: 24), - - // Période - Text('Période de facturation', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - ..._periodes.map((p) { - final (code, label, remise, _) = p; - final selected = _selectedPeriode == code; - return RadioListTile( - value: code, - groupValue: _selectedPeriode, - onChanged: (v) => setState(() => _selectedPeriode = v!), - title: Text(label, - style: TextStyle( - fontWeight: - selected ? FontWeight.bold : FontWeight.normal)), - subtitle: Text(remise, - style: TextStyle( - color: code != 'MENSUEL' ? Colors.green[700] : null)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: selected - ? theme.primaryColor - : Colors.grey[300]!, - ), - ), - tileColor: - selected ? theme.primaryColor.withOpacity(0.05) : null, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - ); - }), - - const SizedBox(height: 24), - Text('Type de votre organisation', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - 'Détermine le coefficient tarifaire applicable.', - style: - theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), - ), - const SizedBox(height: 8), - ..._typesOrg.map((t) { - final (code, label, coef) = t; - final selected = _selectedTypeOrg == code; - return RadioListTile( - value: code, - groupValue: _selectedTypeOrg, - onChanged: (v) => setState(() => _selectedTypeOrg = v!), - title: Text(label), - subtitle: Text('Coefficient : $coef', - style: const TextStyle(fontSize: 12)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: selected ? theme.primaryColor : Colors.grey[300]!, - ), - ), - tileColor: - selected ? theme.primaryColor.withOpacity(0.05) : null, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - ); - }), - - const SizedBox(height: 24), - // Estimation du prix - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.primaryColor.withOpacity(0.08), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + backgroundColor: UnionFlowColors.background, + body: Column( + children: [ + OnboardingStepHeader( + step: 2, + total: 3, + title: 'Période de facturation', + subtitle: 'Choisissez votre rythme de paiement.\nPlus la période est longue, plus vous économisez.', + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Estimation', style: TextStyle(fontWeight: FontWeight.bold)), - Text( - '${_prixEstime.toStringAsFixed(0)} FCFA', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: theme.primaryColor, + // Rappel formule sélectionnée + _FormulaRecap( + codeFormule: widget.codeFormule, + plage: widget.plage, + prixMensuel: _formule?.prixMensuel ?? 0, + ), + const SizedBox(height: 24), + OnboardingSectionTitle( + icon: Icons.calendar_month_outlined, + title: 'Choisissez votre période', + ), + const SizedBox(height: 12), + ..._periodes.map((p) => _PeriodeCard( + periode: p, + selected: _selectedPeriode == p.code, + prixTotal: _estimerPrix(p.code), + onTap: () => setState(() => _selectedPeriode = p.code), + )), + const SizedBox(height: 24), + // Total estimé + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: UnionFlowColors.primaryGradient, + borderRadius: BorderRadius.circular(16), + boxShadow: UnionFlowColors.greenGlowShadow, + ), + child: Row( + children: [ + const Icon(Icons.calculate_outlined, + color: Colors.white70, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Estimation indicative', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + const SizedBox(height: 2), + const Text( + 'Le montant exact est calculé par le système.', + style: TextStyle( + color: Colors.white70, fontSize: 11), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatPrix(prixSelected), + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + const Text( + 'FCFA', + style: + TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + // Note info + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: UnionFlowColors.infoPale, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: UnionFlowColors.info.withOpacity(0.2)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, + color: UnionFlowColors.info, size: 18), + SizedBox(width: 10), + Expanded( + child: Text( + 'Le type de votre organisation et le coefficient tarifaire exact sont déterminés lors de la création de votre compte. Le récapitulatif final vous montrera le montant précis.', + style: TextStyle( + fontSize: 12, + color: UnionFlowColors.info, + height: 1.4), + ), + ), + ], ), ), ], ), ), - const SizedBox(height: 32), - ], - ), + ), + ], ), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton( - onPressed: () { - context.read() - ..add(OnboardingPeriodeSelected( - typePeriode: _selectedPeriode, - typeOrganisation: _selectedTypeOrg, - organisationId: _organisationId, - )) - ..add(const OnboardingDemandeConfirmee()); - }, - style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(48)), - child: const Text('Voir le récapitulatif'), - ), + bottomNavigationBar: OnboardingBottomBar( + enabled: true, + label: 'Voir le récapitulatif', + onPressed: () { + context.read() + ..add(OnboardingPeriodeSelected( + typePeriode: _selectedPeriode, + typeOrganisation: '', + organisationId: _organisationId, + )) + ..add(const OnboardingDemandeConfirmee()); + }, ), ); } + + String _formatPrix(double prix) { + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + if (prix >= 1000) { + final parts = prix.toStringAsFixed(0); + if (parts.length > 3) { + return '${parts.substring(0, parts.length - 3)} ${parts.substring(parts.length - 3)}'; + } + } + return prix.toStringAsFixed(0); + } } -class _StepIndicator extends StatelessWidget { - final int current; - final int total; - const _StepIndicator({required this.current, required this.total}); +class _Periode { + final String code, label, duree; + final String? badge; + final int nbMois; + final double coef; + const _Periode(this.code, this.label, this.duree, this.badge, this.nbMois, this.coef); +} + +class _FormulaRecap extends StatelessWidget { + final String codeFormule; + final String plage; + final double prixMensuel; + + const _FormulaRecap({ + required this.codeFormule, + required this.plage, + required this.prixMensuel, + }); + + static const _plageLabels = { + 'PETITE': '1–100 membres', + 'MOYENNE': '101–500 membres', + 'GRANDE': '501–2 000 membres', + 'TRES_GRANDE': '2 000+ membres', + }; @override Widget build(BuildContext context) { - return Row( - children: List.generate(total, (i) { - final active = i + 1 <= current; - return Expanded( - child: Container( - height: 4, - margin: EdgeInsets.only(right: i < total - 1 ? 4 : 0), - decoration: BoxDecoration( - color: active ? Theme.of(context).primaryColor : Colors.grey[300], - borderRadius: BorderRadius.circular(2), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: UnionFlowColors.unionGreenPale, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: UnionFlowColors.unionGreen.withOpacity(0.25)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle_rounded, + color: UnionFlowColors.unionGreen, size: 20), + const SizedBox(width: 10), + Expanded( + child: RichText( + text: TextSpan( + style: const TextStyle( + color: UnionFlowColors.textPrimary, fontSize: 13), + children: [ + TextSpan( + text: 'Formule $codeFormule', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + const TextSpan(text: ' · '), + TextSpan( + text: _plageLabels[plage] ?? plage, + style: const TextStyle( + color: UnionFlowColors.textSecondary), + ), + ], + ), ), ), - ); - }), + Text( + '${_formatPrix(prixMensuel)} FCFA/mois', + style: const TextStyle( + color: UnionFlowColors.unionGreen, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + ], + ), ); } + + String _formatPrix(double prix) { + if (prix >= 1000) { + final k = (prix / 1000).toStringAsFixed(0); + return '$k 000'; + } + return prix.toStringAsFixed(0); + } +} + +class _PeriodeCard extends StatelessWidget { + final _Periode periode; + final bool selected; + final double prixTotal; + final VoidCallback onTap; + + const _PeriodeCard({ + required this.periode, + required this.selected, + required this.prixTotal, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: selected ? UnionFlowColors.unionGreenPale : UnionFlowColors.surface, + border: Border.all( + color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border, + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: selected ? UnionFlowColors.greenGlowShadow : UnionFlowColors.softShadow, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon( + selected ? Icons.check_circle_rounded : Icons.radio_button_unchecked, + color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border, + size: 22, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + periode.label, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.textPrimary, + ), + ), + if (periode.badge != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: UnionFlowColors.successPale, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + periode.badge!, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: UnionFlowColors.success, + ), + ), + ), + ], + ], + ), + Text( + periode.duree, + style: const TextStyle( + fontSize: 12, + color: UnionFlowColors.textSecondary), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '~ ${_formatPrix(prixTotal)}', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.textPrimary, + ), + ), + const Text( + 'FCFA', + style: TextStyle( + fontSize: 11, + color: UnionFlowColors.textSecondary), + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _formatPrix(double prix) { + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + if (prix >= 1000) { + final s = prix.toStringAsFixed(0); + if (s.length > 3) { + return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; + } + } + return prix.toStringAsFixed(0); + } } diff --git a/lib/features/onboarding/presentation/pages/plan_selection_page.dart b/lib/features/onboarding/presentation/pages/plan_selection_page.dart index 92d39e9..9b654e9 100644 --- a/lib/features/onboarding/presentation/pages/plan_selection_page.dart +++ b/lib/features/onboarding/presentation/pages/plan_selection_page.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/formule_model.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; +import 'onboarding_shared_widgets.dart'; -/// Étape 1 — Choix de la formule (BASIC / STANDARD / PREMIUM) et de la plage de membres +/// Étape 1 — Choix de la taille de l'organisation et du niveau de formule class PlanSelectionPage extends StatefulWidget { final List formules; const PlanSelectionPage({super.key, required this.formules}); @@ -17,121 +19,214 @@ class _PlanSelectionPageState extends State { String? _selectedFormule; static const _plages = [ - ('PETITE', 'Petite', '1–100 membres'), - ('MOYENNE', 'Moyenne', '101–500 membres'), - ('GRANDE', 'Grande', '501–2 000 membres'), - ('TRES_GRANDE', 'Très grande', '2 000+ membres'), + _Plage('PETITE', 'Petite', '1 – 100 membres', Icons.group_outlined, 'Associations naissantes et petites structures'), + _Plage('MOYENNE', 'Moyenne', '101 – 500 membres', Icons.groups_outlined, 'Associations établies en croissance'), + _Plage('GRANDE', 'Grande', '501 – 2 000 membres', Icons.corporate_fare_outlined, 'Grandes organisations régionales'), + _Plage('TRES_GRANDE', 'Très grande', '2 000+ membres', Icons.account_balance_outlined, 'Fédérations et réseaux nationaux'), ]; - static const _formules = [ - ('BASIC', 'Basic', Icons.star_outline, Color(0xFF1976D2)), - ('STANDARD', 'Standard', Icons.star_half, Color(0xFF388E3C)), - ('PREMIUM', 'Premium', Icons.star, Color(0xFFF57C00)), - ]; + static const _formuleColors = { + 'BASIC': UnionFlowColors.unionGreen, + 'STANDARD': UnionFlowColors.gold, + 'PREMIUM': UnionFlowColors.indigo, + }; + + static const _formuleIcons = { + 'BASIC': Icons.star_border_rounded, + 'STANDARD': Icons.star_half_rounded, + 'PREMIUM': Icons.star_rounded, + }; + + static const _formuleFeatures = { + 'BASIC': ['Gestion des membres', 'Cotisations de base', 'Rapports mensuels', 'Support email'], + 'STANDARD': ['Tout Basic +', 'Événements & solidarité', 'Communication interne', 'Tableaux de bord avancés', 'Support prioritaire'], + 'PREMIUM': ['Tout Standard +', 'Multi-organisations', 'Analytics temps réel', 'API ouverte', 'Support dédié 24/7'], + }; List get _filteredFormules => widget.formules .where((f) => _selectedPlage == null || f.plage == _selectedPlage) .toList() ..sort((a, b) => a.ordreAffichage.compareTo(b.ordreAffichage)); - FormuleModel? get _selectedFormuleModel => _filteredFormules - .where((f) => f.code == _selectedFormule && f.plage == _selectedPlage) - .firstOrNull; + bool get _canProceed => _selectedPlage != null && _selectedFormule != null; @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( - appBar: AppBar( - title: const Text('Choisir votre formule'), - automaticallyImplyLeading: false, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Indicateur d'étapes - _StepIndicator(current: 1, total: 3), - const SizedBox(height: 24), - - Text('Taille de votre organisation', - style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - // Sélecteur de plage - Wrap( - spacing: 8, - runSpacing: 8, - children: _plages.map((p) { - final (code, label, sublabel) = p; - final selected = _selectedPlage == code; - return ChoiceChip( - label: Column( - children: [ - Text(label, - style: TextStyle( - fontWeight: FontWeight.bold, - color: selected ? Colors.white : null)), - Text(sublabel, - style: TextStyle( - fontSize: 11, - color: selected - ? Colors.white70 - : Colors.grey[600])), - ], + backgroundColor: UnionFlowColors.background, + body: Column( + children: [ + OnboardingStepHeader( + step: 1, + total: 3, + title: 'Choisissez votre formule', + subtitle: 'Sélectionnez la taille de votre organisation\npuis le niveau d\'abonnement adapté.', + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Step 1a: Taille de l'organisation + OnboardingSectionTitle( + icon: Icons.people_alt_outlined, + title: 'Taille de votre organisation', ), - selected: selected, - onSelected: (_) => setState(() { - _selectedPlage = code; - _selectedFormule = null; - }), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ); - }).toList(), - ), + const SizedBox(height: 12), + ...(_plages.map((p) => _PlageCard( + plage: p, + selected: _selectedPlage == p.code, + onTap: () => setState(() { + _selectedPlage = p.code; + _selectedFormule = null; + }), + ))), - if (_selectedPlage != null) ...[ - const SizedBox(height: 24), - Text('Niveau de formule', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - // Cartes de formules - ..._formules.map((f) { - final (code, label, icon, color) = f; - final formule = _filteredFormules - .where((fm) => fm.code == code) - .firstOrNull; - if (formule == null) return const SizedBox.shrink(); - final selected = _selectedFormule == code; - return _FormuleCard( - formule: formule, - label: label, - icon: icon, - color: color, - selected: selected, - onTap: () => setState(() => _selectedFormule = code), - ); - }), - ], - const SizedBox(height: 32), - ], + if (_selectedPlage != null) ...[ + const SizedBox(height: 28), + OnboardingSectionTitle( + icon: Icons.workspace_premium_outlined, + title: 'Niveau d\'abonnement', + ), + const SizedBox(height: 12), + ..._filteredFormules.map((f) => _FormuleCard( + formule: f, + color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen, + icon: _formuleIcons[f.code] ?? Icons.star_border_rounded, + features: _formuleFeatures[f.code] ?? [], + selected: _selectedFormule == f.code, + onTap: () => setState(() => _selectedFormule = f.code), + )), + ], + ], + ), + ), + ), + ], + ), + bottomNavigationBar: OnboardingBottomBar( + enabled: _canProceed, + label: 'Choisir la période', + onPressed: () => context.read().add( + OnboardingFormuleSelected( + codeFormule: _selectedFormule!, + plage: _selectedPlage!, + ), ), ), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton( - onPressed: _selectedPlage != null && _selectedFormule != null - ? () => context.read().add( - OnboardingFormuleSelected( - codeFormule: _selectedFormule!, - plage: _selectedPlage!, - ), - ) - : null, - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), + ); + } +} + +// ─── Widgets locaux ────────────────────────────────────────────────────────── + +class _Plage { + final String code, label, sublabel, description; + final IconData icon; + const _Plage(this.code, this.label, this.sublabel, this.icon, this.description); +} + +class _PlageCard extends StatelessWidget { + final _Plage plage; + final bool selected; + final VoidCallback onTap; + const _PlageCard({required this.plage, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: selected ? UnionFlowColors.unionGreenPale : UnionFlowColors.surface, + border: Border.all( + color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border, + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: selected ? UnionFlowColors.greenGlowShadow : UnionFlowColors.softShadow, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.unionGreenPale, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(plage.icon, + color: selected ? Colors.white : UnionFlowColors.unionGreen, + size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + plage.label, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: selected + ? UnionFlowColors.unionGreen.withOpacity(0.15) + : UnionFlowColors.surfaceVariant, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + plage.sublabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + plage.description, + style: const TextStyle( + fontSize: 12, + color: UnionFlowColors.textSecondary), + ), + ], + ), + ), + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked, + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.border, + size: 22, + ), + ], ), - child: const Text('Continuer'), ), ), ); @@ -140,17 +235,17 @@ class _PlanSelectionPageState extends State { class _FormuleCard extends StatelessWidget { final FormuleModel formule; - final String label; - final IconData icon; final Color color; + final IconData icon; + final List features; final bool selected; final VoidCallback onTap; const _FormuleCard({ required this.formule, - required this.label, - required this.icon, required this.color, + required this.icon, + required this.features, required this.selected, required this.onTap, }); @@ -161,92 +256,125 @@ class _FormuleCard extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 8), + margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( - color: selected ? color.withOpacity(0.1) : Colors.white, + color: UnionFlowColors.surface, border: Border.all( - color: selected ? color : Colors.grey[300]!, - width: selected ? 2 : 1, + color: selected ? color : UnionFlowColors.border, + width: selected ? 2.5 : 1, ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), + boxShadow: selected + ? [ + BoxShadow( + color: color.withOpacity(0.2), + blurRadius: 20, + offset: const Offset(0, 8), + ) + ] + : UnionFlowColors.softShadow, ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(icon, color: color, size: 28), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: selected ? color : null)), - if (formule.description != null) - Text(formule.description!, - style: const TextStyle(fontSize: 12, color: Colors.grey)), - ], - ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: selected ? color : color.withOpacity(0.06), + borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + child: Row( children: [ - Text( - '${_formatPrix(formule.prixMensuel)} FCFA', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: color), + Icon(icon, + color: selected ? Colors.white : color, size: 24), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + formule.libelle, + style: TextStyle( + color: selected ? Colors.white : color, + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + if (formule.description != null) + Text( + formule.description!, + style: TextStyle( + color: selected + ? Colors.white70 + : UnionFlowColors.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatPrix(formule.prixMensuel), + style: TextStyle( + color: selected ? Colors.white : color, + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + Text( + 'FCFA / mois', + style: TextStyle( + color: selected + ? Colors.white70 + : UnionFlowColors.textSecondary, + fontSize: 11, + ), + ), + ], ), - const Text('/mois', style: TextStyle(fontSize: 11, color: Colors.grey)), ], ), - const SizedBox(width: 8), - Icon( - selected ? Icons.check_circle : Icons.radio_button_unchecked, - color: selected ? color : Colors.grey, + ), + // Features + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + child: Column( + children: features + .map((f) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon(Icons.check_circle_outline_rounded, + size: 16, color: color), + const SizedBox(width: 8), + Text(f, + style: const TextStyle( + fontSize: 13, + color: UnionFlowColors.textPrimary)), + ], + ), + )) + .toList(), ), - ], - ), + ), + ], ), ), ); } String _formatPrix(double prix) { + if (prix >= 1000000) { + return '${(prix / 1000000).toStringAsFixed(1)} M'; + } if (prix >= 1000) { - return '${(prix / 1000).toStringAsFixed(0)} 000'; + final k = (prix / 1000).toStringAsFixed(0); + return '$k 000'; } return prix.toStringAsFixed(0); } } -class _StepIndicator extends StatelessWidget { - final int current; - final int total; - const _StepIndicator({required this.current, required this.total}); - - @override - Widget build(BuildContext context) { - return Row( - children: List.generate(total, (i) { - final active = i + 1 <= current; - return Expanded( - child: Container( - height: 4, - margin: EdgeInsets.only(right: i < total - 1 ? 4 : 0), - decoration: BoxDecoration( - color: active - ? Theme.of(context).primaryColor - : Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ); - }), - ); - } -} diff --git a/lib/features/onboarding/presentation/pages/subscription_summary_page.dart b/lib/features/onboarding/presentation/pages/subscription_summary_page.dart index 4613e65..a8eb7ff 100644 --- a/lib/features/onboarding/presentation/pages/subscription_summary_page.dart +++ b/lib/features/onboarding/presentation/pages/subscription_summary_page.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/souscription_status_model.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; -/// Étape 3 — Récapitulatif avant paiement +/// Étape 3 — Récapitulatif détaillé avant paiement class SubscriptionSummaryPage extends StatelessWidget { final SouscriptionStatusModel souscription; @@ -11,160 +12,507 @@ class SubscriptionSummaryPage extends StatelessWidget { static const _periodeLabels = { 'MENSUEL': 'Mensuel', - 'TRIMESTRIEL': 'Trimestriel (–5%)', - 'SEMESTRIEL': 'Semestriel (–10%)', - 'ANNUEL': 'Annuel (–20%)', + 'TRIMESTRIEL': 'Trimestriel', + 'SEMESTRIEL': 'Semestriel', + 'ANNUEL': 'Annuel', + }; + + static const _periodeRemises = { + 'MENSUEL': null, + 'TRIMESTRIEL': '–5% de remise', + 'SEMESTRIEL': '–10% de remise', + 'ANNUEL': '–20% de remise', }; static const _orgLabels = { 'ASSOCIATION': 'Association / ONG locale', - 'MUTUELLE': 'Mutuelle', + 'MUTUELLE': 'Mutuelle (santé, fonctionnaires…)', 'COOPERATIVE': 'Coopérative / Microfinance', 'FEDERATION': 'Fédération / Grande ONG', }; + static const _plageLabels = { + 'PETITE': '1–100 membres', + 'MOYENNE': '101–500 membres', + 'GRANDE': '501–2 000 membres', + 'TRES_GRANDE': '2 000+ membres', + }; + @override Widget build(BuildContext context) { - final theme = Theme.of(context); final montant = souscription.montantTotal ?? 0; + final remise = _periodeRemises[souscription.typePeriode]; return Scaffold( - appBar: AppBar( - title: const Text('Récapitulatif de la souscription'), - automaticallyImplyLeading: false, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // En-tête - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [theme.primaryColor, theme.primaryColor.withOpacity(0.7)], + backgroundColor: UnionFlowColors.background, + body: Column( + children: [ + // Header hero + Container( + decoration: const BoxDecoration( + gradient: UnionFlowColors.primaryGradient, + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + child: Column( + children: [ + // Step bar + Row( + children: List.generate(3, (i) => Expanded( + child: Container( + height: 4, + margin: EdgeInsets.only(right: i < 2 ? 6 : 0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(2), + ), + ), + )), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Étape 3 sur 3', + style: TextStyle( + color: Colors.white.withOpacity(0.75), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 20), + // Montant principal + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.4), width: 2), + ), + child: const Icon(Icons.receipt_long_rounded, + color: Colors.white, size: 44), + ), + const SizedBox(height: 14), + Text( + _formatPrix(montant), + style: const TextStyle( + color: Colors.white, + fontSize: 40, + fontWeight: FontWeight.w900, + letterSpacing: -1, + ), + ), + const Text( + 'FCFA à régler', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + if (remise != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: UnionFlowColors.gold.withOpacity(0.3), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: UnionFlowColors.goldLight.withOpacity(0.5)), + ), + child: Text( + remise, + style: const TextStyle( + color: UnionFlowColors.goldLight, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], ), - borderRadius: BorderRadius.circular(16), ), + ), + ), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.receipt_long, color: Colors.white, size: 40), - const SizedBox(height: 8), - Text( - '${montant.toStringAsFixed(0)} FCFA', - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.bold, + // Organisation + if (souscription.organisationNom != null) ...[ + _DetailCard( + title: 'Organisation', + icon: Icons.business_rounded, + iconColor: UnionFlowColors.indigo, + items: [ + _DetailItem( + label: 'Nom', + value: souscription.organisationNom!, + bold: true), + _DetailItem( + label: 'Type', + value: _orgLabels[souscription.typeOrganisation] ?? + souscription.typeOrganisation), + ], + ), + const SizedBox(height: 14), + ], + + // Formule + _DetailCard( + title: 'Formule souscrite', + icon: Icons.workspace_premium_rounded, + iconColor: UnionFlowColors.gold, + items: [ + _DetailItem( + label: 'Niveau', + value: souscription.typeFormule, + bold: true), + _DetailItem( + label: 'Taille', + value: _plageLabels[souscription.plageMembres] ?? + souscription.plageLibelle), + if (souscription.montantMensuelBase != null) + _DetailItem( + label: 'Prix de base', + value: + '${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois'), + ], + ), + const SizedBox(height: 14), + + // Facturation + _DetailCard( + title: 'Facturation', + icon: Icons.calendar_today_rounded, + iconColor: UnionFlowColors.unionGreen, + items: [ + _DetailItem( + label: 'Période', + value: + _periodeLabels[souscription.typePeriode] ?? + souscription.typePeriode), + if (souscription.coefficientApplique != null) + _DetailItem( + label: 'Coefficient', + value: + '×${souscription.coefficientApplique!.toStringAsFixed(4)}'), + if (souscription.dateDebut != null && + souscription.dateFin != null) ...[ + _DetailItem( + label: 'Début', + value: _formatDate(souscription.dateDebut!)), + _DetailItem( + label: 'Fin', + value: _formatDate(souscription.dateFin!)), + ], + ], + ), + const SizedBox(height: 14), + + // Montant total + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: UnionFlowColors.goldPale, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: UnionFlowColors.gold.withOpacity(0.4)), + boxShadow: UnionFlowColors.goldGlowShadow, + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: UnionFlowColors.goldGradient, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.monetization_on_rounded, + color: Colors.white, size: 26), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Total à payer', + style: TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 13), + ), + Text( + '${_formatPrix(montant)} FCFA', + style: const TextStyle( + color: UnionFlowColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ], ), ), - Text( - 'à régler par Wave Mobile Money', - style: TextStyle(color: Colors.white.withOpacity(0.85)), + const SizedBox(height: 20), + + // Notes importantes + _NoteBox( + icon: Icons.security_rounded, + iconColor: UnionFlowColors.unionGreen, + backgroundColor: UnionFlowColors.unionGreenPale, + borderColor: UnionFlowColors.unionGreen.withOpacity(0.25), + title: 'Paiement sécurisé', + message: + 'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois le paiement effectué, votre compte sera activé automatiquement.', + ), + const SizedBox(height: 10), + _NoteBox( + icon: Icons.bolt_rounded, + iconColor: UnionFlowColors.amber, + backgroundColor: const Color(0xFFFFFBF0), + borderColor: UnionFlowColors.amber.withOpacity(0.3), + title: 'Activation immédiate', + message: + 'Dès que le paiement est confirmé par Wave, votre compte d\'administrateur est activé et vous pouvez accéder à toutes les fonctionnalités de votre formule.', + ), + const SizedBox(height: 10), + _NoteBox( + icon: Icons.support_agent_rounded, + iconColor: UnionFlowColors.info, + backgroundColor: UnionFlowColors.infoPale, + borderColor: UnionFlowColors.info.withOpacity(0.2), + title: 'Besoin d\'aide ?', + message: + 'En cas de problème lors du paiement, contactez notre support à support@unionflow.app — nous vous répondrons sous 24h.', ), ], ), ), - const SizedBox(height: 24), - - Text('Détails de votre souscription', style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - - _InfoRow(label: 'Organisation', value: souscription.organisationNom ?? '—'), - _InfoRow(label: 'Formule', value: souscription.typeFormule), - _InfoRow(label: 'Plage de membres', value: souscription.plageLibelle), - _InfoRow( - label: 'Période', - value: _periodeLabels[souscription.typePeriode] ?? souscription.typePeriode, + ), + ], + ), + bottomNavigationBar: Container( + padding: EdgeInsets.fromLTRB( + 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, -4), ), - _InfoRow( - label: 'Type d\'organisation', - value: _orgLabels[souscription.typeOrganisation] ?? souscription.typeOrganisation, - ), - if (souscription.coefficientApplique != null) - _InfoRow( - label: 'Coefficient appliqué', - value: '×${souscription.coefficientApplique!.toStringAsFixed(2)}', - ), - - const Divider(height: 32), - _InfoRow( - label: 'Total à payer', - value: '${montant.toStringAsFixed(0)} FCFA', - bold: true, - ), - - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon(Icons.info_outline, color: Colors.blue, size: 20), - SizedBox(width: 8), - Expanded( - child: Text( - 'Vous allez être redirigé vers Wave pour effectuer le paiement. ' - 'Votre accès sera activé après validation par un administrateur.', - style: TextStyle(fontSize: 13, color: Colors.blue), - ), - ), - ], - ), - ), - const SizedBox(height: 32), ], ), - ), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton.icon( - onPressed: () => - context.read().add(const OnboardingPaiementInitie()), - icon: const Icon(Icons.payment), - label: const Text('Payer avec Wave'), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(52), - backgroundColor: const Color(0xFF00B9F1), // Couleur Wave - foregroundColor: Colors.white, + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => context + .read() + .add(const OnboardingChoixPaiementOuvert()), + icon: const Icon(Icons.payment_rounded), + label: const Text( + 'Choisir le moyen de paiement', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), + elevation: 3, + ), ), ), ), ); } + + String _formatPrix(double prix) { + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + final s = prix.toStringAsFixed(0); + if (s.length > 6) { + return '${s.substring(0, s.length - 6)} ${s.substring(s.length - 6, s.length - 3)} ${s.substring(s.length - 3)}'; + } + if (s.length > 3) { + return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; + } + return s; + } + + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } } -class _InfoRow extends StatelessWidget { +// ─── Widgets locaux ────────────────────────────────────────────────────────── + +class _DetailItem { final String label; final String value; final bool bold; + const _DetailItem( + {required this.label, required this.value, this.bold = false}); +} - const _InfoRow({required this.label, required this.value, this.bold = false}); +class _DetailCard extends StatelessWidget { + final String title; + final IconData icon; + final Color iconColor; + final List<_DetailItem> items; + + const _DetailCard({ + required this.title, + required this.icon, + required this.iconColor, + required this.items, + }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( + return Container( + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: UnionFlowColors.softShadow, + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 160, - child: Text(label, - style: const TextStyle(color: Colors.grey, fontSize: 14)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: UnionFlowColors.textPrimary, + ), + ), + ], + ), ), - Expanded( - child: Text( - value, - style: TextStyle( - fontWeight: bold ? FontWeight.bold : FontWeight.normal, - fontSize: bold ? 16 : 14, - ), + const Divider(height: 1, color: UnionFlowColors.border), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + child: Column( + children: items.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + item.label, + style: const TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 13), + ), + ), + Expanded( + child: Text( + item.value, + style: TextStyle( + color: UnionFlowColors.textPrimary, + fontSize: 13, + fontWeight: item.bold + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + ], + ), + )).toList(), + ), + ), + ], + ), + ); + } +} + +class _NoteBox extends StatelessWidget { + final IconData icon; + final Color iconColor; + final Color backgroundColor; + final Color borderColor; + final String title; + final String message; + + const _NoteBox({ + required this.icon, + required this.iconColor, + required this.backgroundColor, + required this.borderColor, + required this.title, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: iconColor, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + const SizedBox(height: 3), + Text( + message, + style: const TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 12, + height: 1.5), + ), + ], ), ), ], diff --git a/lib/features/onboarding/presentation/pages/wave_payment_page.dart b/lib/features/onboarding/presentation/pages/wave_payment_page.dart index 4879923..9c05b06 100644 --- a/lib/features/onboarding/presentation/pages/wave_payment_page.dart +++ b/lib/features/onboarding/presentation/pages/wave_payment_page.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/souscription_status_model.dart'; +import '../../../../shared/design_system/tokens/unionflow_colors.dart'; +import '../../../../core/config/environment.dart'; /// Étape 4 — Lancement du paiement Wave + attente du retour class WavePaymentPage extends StatefulWidget { @@ -23,6 +25,13 @@ class _WavePaymentPageState extends State with WidgetsBindingObserver { bool _paymentLaunched = false; bool _appResumed = false; + bool _simulating = false; + + /// En dev/mock, la session Wave ne peut pas s'ouvrir — on simule directement. + bool get _isMock => + widget.waveLaunchUrl.contains('mock') || + widget.waveLaunchUrl.contains('localhost') || + !AppConfig.isProd; @override void initState() { @@ -38,14 +47,24 @@ class _WavePaymentPageState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - // Quand l'utilisateur revient dans l'app après Wave if (state == AppLifecycleState.resumed && _paymentLaunched && !_appResumed) { _appResumed = true; context.read().add(const OnboardingRetourDepuisWave()); } } - Future _lancerWave() async { + Future _lancerOuSimuler() async { + if (_isMock) { + // Mode dev/mock : simuler le paiement directement + setState(() => _simulating = true); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) { + context.read().add(const OnboardingRetourDepuisWave()); + } + return; + } + + // Mode prod : ouvrir Wave final uri = Uri.parse(widget.waveLaunchUrl); if (await canLaunchUrl(uri)) { setState(() => _paymentLaunched = true); @@ -54,8 +73,10 @@ class _WavePaymentPageState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Impossible d\'ouvrir Wave. Vérifiez que l\'application est installée.'), - backgroundColor: Colors.red, + content: Text( + 'Impossible d\'ouvrir Wave. Vérifiez que l\'application est installée.'), + backgroundColor: UnionFlowColors.error, + behavior: SnackBarBehavior.floating, ), ); } @@ -65,81 +86,344 @@ class _WavePaymentPageState extends State @override Widget build(BuildContext context) { final montant = widget.souscription.montantTotal ?? 0; + const waveBlue = Color(0xFF00B9F1); return Scaffold( - appBar: AppBar( - title: const Text('Paiement Wave'), - automaticallyImplyLeading: false, - ), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Logo Wave stylisé - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: const Color(0xFF00B9F1), - borderRadius: BorderRadius.circular(24), + backgroundColor: UnionFlowColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + children: [ + if (!_paymentLaunched && !_simulating) + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded), + color: UnionFlowColors.textSecondary, + ), + const Spacer(), + if (_isMock) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: UnionFlowColors.warningPale, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: UnionFlowColors.warning.withOpacity(0.4)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.science_rounded, + size: 13, color: UnionFlowColors.warning), + SizedBox(width: 4), + Text( + 'Mode dev', + style: TextStyle( + color: UnionFlowColors.warning, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: waveBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Wave Mobile Money', + style: TextStyle( + color: waveBlue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), - child: const Icon(Icons.waves, color: Colors.white, size: 52), - ), - const SizedBox(height: 32), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_simulating) ...[ + // Animation de simulation + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF00B9F1), Color(0xFF0096C7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: waveBlue.withOpacity(0.35), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: const Center( + child: SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + ), + ), + const SizedBox(height: 28), + const Text( + 'Simulation du paiement…', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Confirmation en cours', + style: TextStyle( + color: UnionFlowColors.textSecondary, fontSize: 14), + ), + ] else ...[ + // Logo Wave + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: UnionFlowColors.border), + boxShadow: [ + BoxShadow( + color: waveBlue.withOpacity(0.2), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Image.asset( + 'assets/images/payment_methods/wave/logo.png', + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.waves_rounded, + color: waveBlue, + size: 52, + ), + ), + ), + const SizedBox(height: 28), - const Text( - 'Paiement par Wave', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'Montant : ${montant.toStringAsFixed(0)} FCFA', - style: const TextStyle(fontSize: 18, color: Colors.grey), - ), - const SizedBox(height: 32), + Text( + _paymentLaunched + ? 'Paiement en cours…' + : _isMock + ? 'Simuler le paiement' + : 'Prêt à payer', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: UnionFlowColors.textPrimary, + ), + ), + const SizedBox(height: 8), - if (!_paymentLaunched) ...[ - const Text( - 'Cliquez sur le bouton ci-dessous pour ouvrir Wave et effectuer votre paiement.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _lancerWave, - icon: const Icon(Icons.open_in_new), - label: const Text('Ouvrir Wave'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(200, 52), - backgroundColor: const Color(0xFF00B9F1), - foregroundColor: Colors.white, + // Montant + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${_formatPrix(montant)} ', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w900, + color: waveBlue, + letterSpacing: -0.5, + ), + ), + const TextSpan( + text: 'FCFA', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textSecondary, + ), + ), + ], + ), + ), + if (widget.souscription.organisationNom != null) ...[ + const SizedBox(height: 4), + Text( + widget.souscription.organisationNom!, + style: const TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 13), + ), + ], + const SizedBox(height: 32), + + if (!_paymentLaunched) ...[ + if (_isMock) + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UnionFlowColors.warningPale, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: + UnionFlowColors.warning.withOpacity(0.3)), + ), + child: const Row( + children: [ + Icon(Icons.science_outlined, + color: UnionFlowColors.warning, size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + 'Environnement de développement — le paiement sera simulé automatiquement.', + style: TextStyle( + fontSize: 12, + color: UnionFlowColors.warning, + height: 1.4), + ), + ), + ], + ), + ), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _lancerOuSimuler, + icon: Icon(_isMock + ? Icons.play_circle_rounded + : Icons.open_in_new_rounded), + label: Text( + _isMock + ? 'Simuler le paiement Wave' + : 'Ouvrir Wave', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: waveBlue, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + shadowColor: waveBlue.withOpacity(0.4), + elevation: 3, + ), + ), + ), + ] else ...[ + // Paiement lancé en prod + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: UnionFlowColors.softShadow, + ), + child: Column( + children: [ + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + color: waveBlue, + strokeWidth: 3, + ), + ), + const SizedBox(height: 16), + const Text( + 'Paiement en cours dans Wave', + style: TextStyle( + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, + ), + ), + const SizedBox(height: 6), + const Text( + 'Revenez dans l\'app une fois\nvotre paiement confirmé.', + textAlign: TextAlign.center, + style: TextStyle( + color: UnionFlowColors.textSecondary, + fontSize: 13, + height: 1.4), + ), + ], + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => context + .read() + .add(const OnboardingRetourDepuisWave()), + icon: const Icon( + Icons.check_circle_outline_rounded), + label: const Text( + 'J\'ai effectué le paiement', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + ), + ), + ), + const SizedBox(height: 10), + TextButton.icon( + onPressed: _lancerOuSimuler, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Rouvrir Wave'), + style: TextButton.styleFrom( + foregroundColor: waveBlue), + ), + ], + ], + ], ), ), - ] else ...[ - const Icon(Icons.hourglass_top, size: 40, color: Colors.orange), - const SizedBox(height: 16), - const Text( - 'Paiement en cours dans Wave…\nRevenez ici une fois le paiement effectué.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - OutlinedButton( - onPressed: () => context.read().add( - const OnboardingRetourDepuisWave(), - ), - child: const Text('J\'ai effectué le paiement'), - ), - const SizedBox(height: 12), - TextButton( - onPressed: _lancerWave, - child: const Text('Rouvrir Wave'), - ), ], - ], + ), ), ), ); } + + String _formatPrix(double prix) { + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + final s = prix.toStringAsFixed(0); + if (s.length > 3) { + return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; + } + return s; + } } diff --git a/lib/features/organizations/bloc/org_switcher_bloc.dart b/lib/features/organizations/bloc/org_switcher_bloc.dart new file mode 100644 index 0000000..1b7e73c --- /dev/null +++ b/lib/features/organizations/bloc/org_switcher_bloc.dart @@ -0,0 +1,144 @@ +/// BLoC pour le sélecteur d'organisation multi-org. +/// +/// Charge GET /api/membres/mes-organisations et maintient +/// l'organisation active via [OrgContextService]. +library org_switcher_bloc; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:injectable/injectable.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/api_client.dart'; +import '../../../core/network/org_context_service.dart'; +import '../../../core/utils/logger.dart'; +import '../data/models/org_switcher_entry.dart'; + +// ─── ÉVÉNEMENTS ───────────────────────────────────────────────────────────── + +abstract class OrgSwitcherEvent extends Equatable { + const OrgSwitcherEvent(); + @override + List get props => []; +} + +/// Charge la liste des organisations du membre connecté. +class OrgSwitcherLoadRequested extends OrgSwitcherEvent { + const OrgSwitcherLoadRequested(); +} + +/// Sélectionne une organisation comme active. +class OrgSwitcherSelectRequested extends OrgSwitcherEvent { + final OrgSwitcherEntry organisation; + const OrgSwitcherSelectRequested(this.organisation); + @override + List get props => [organisation]; +} + +// ─── ÉTATS ────────────────────────────────────────────────────────────────── + +abstract class OrgSwitcherState extends Equatable { + const OrgSwitcherState(); + @override + List get props => []; +} + +class OrgSwitcherInitial extends OrgSwitcherState {} + +class OrgSwitcherLoading extends OrgSwitcherState {} + +class OrgSwitcherLoaded extends OrgSwitcherState { + final List organisations; + final OrgSwitcherEntry? active; + + const OrgSwitcherLoaded({required this.organisations, this.active}); + + OrgSwitcherLoaded copyWith({ + List? organisations, + OrgSwitcherEntry? active, + }) { + return OrgSwitcherLoaded( + organisations: organisations ?? this.organisations, + active: active ?? this.active, + ); + } + + @override + List get props => [organisations, active]; +} + +class OrgSwitcherError extends OrgSwitcherState { + final String message; + const OrgSwitcherError(this.message); + @override + List get props => [message]; +} + +// ─── BLOC ──────────────────────────────────────────────────────────────────── + +@injectable +class OrgSwitcherBloc extends Bloc { + final ApiClient _apiClient; + final OrgContextService _orgContextService; + + static const String _endpoint = '/api/membres/mes-organisations'; + + OrgSwitcherBloc(this._apiClient, this._orgContextService) + : super(OrgSwitcherInitial()) { + on(_onLoad); + on(_onSelect); + } + + Future _onLoad( + OrgSwitcherLoadRequested event, + Emitter emit, + ) async { + emit(OrgSwitcherLoading()); + try { + final response = await _apiClient.get>(_endpoint); + final rawList = response.data as List? ?? []; + final orgs = rawList + .map((e) => OrgSwitcherEntry.fromJson(e as Map)) + .toList(); + + // Auto-select si une seule organisation ou si une org est déjà active + OrgSwitcherEntry? active; + if (_orgContextService.hasContext) { + active = orgs.where((o) => o.organisationId == _orgContextService.activeOrganisationId).firstOrNull; + } + active ??= orgs.isNotEmpty ? orgs.first : null; + + if (active != null && !_orgContextService.hasContext) { + _applyActiveOrg(active); + } + + emit(OrgSwitcherLoaded(organisations: orgs, active: active)); + } on DioException catch (e) { + AppLogger.warning('OrgSwitcherBloc: erreur réseau: ${e.message}'); + emit(OrgSwitcherError('Impossible de charger vos organisations: ${e.message}')); + } catch (e) { + AppLogger.error('OrgSwitcherBloc: erreur inattendue: $e'); + emit(OrgSwitcherError('Erreur inattendue: $e')); + } + } + + Future _onSelect( + OrgSwitcherSelectRequested event, + Emitter emit, + ) async { + final current = state; + if (current is! OrgSwitcherLoaded) return; + + _applyActiveOrg(event.organisation); + emit(current.copyWith(active: event.organisation)); + AppLogger.info('OrgSwitcherBloc: sélection → ${event.organisation.nom}'); + } + + void _applyActiveOrg(OrgSwitcherEntry org) { + _orgContextService.setActiveOrganisation( + organisationId: org.organisationId, + nom: org.nom, + type: org.typeOrganisation, + modulesActifsCsv: org.modulesActifs, + ); + } +} diff --git a/lib/features/organizations/data/models/org_switcher_entry.dart b/lib/features/organizations/data/models/org_switcher_entry.dart new file mode 100644 index 0000000..9cc6049 --- /dev/null +++ b/lib/features/organizations/data/models/org_switcher_entry.dart @@ -0,0 +1,73 @@ +/// Modèle pour un item du sélecteur d'organisation. +/// Mappé depuis GET /api/membres/mes-organisations. +library org_switcher_entry; + +import 'package:equatable/equatable.dart'; + +class OrgSwitcherEntry extends Equatable { + final String organisationId; + final String nom; + final String? nomCourt; + final String typeOrganisation; + final String? categorieType; + final String? modulesActifs; + final String? statut; + final String? statutMembre; + final String? roleOrg; + final String? dateAdhesion; + + const OrgSwitcherEntry({ + required this.organisationId, + required this.nom, + this.nomCourt, + required this.typeOrganisation, + this.categorieType, + this.modulesActifs, + this.statut, + this.statutMembre, + this.roleOrg, + this.dateAdhesion, + }); + + /// Libellé court pour le switcher (nomCourt ou nom tronqué). + String get libelleCourt { + if (nomCourt != null && nomCourt!.isNotEmpty) return nomCourt!; + if (nom.length > 25) return '${nom.substring(0, 22)}…'; + return nom; + } + + /// Modules actifs parsés en Set. + Set get modulesActifsSet { + if (modulesActifs == null || modulesActifs!.isEmpty) return {}; + return modulesActifs!.split(',').map((m) => m.trim()).toSet(); + } + + factory OrgSwitcherEntry.fromJson(Map json) { + return OrgSwitcherEntry( + organisationId: json['organisationId']?.toString() ?? '', + nom: json['nom']?.toString() ?? '', + nomCourt: json['nomCourt']?.toString(), + typeOrganisation: json['typeOrganisation']?.toString() ?? '', + categorieType: json['categorieType']?.toString(), + modulesActifs: json['modulesActifs']?.toString(), + statut: json['statut']?.toString(), + statutMembre: json['statutMembre']?.toString(), + roleOrg: json['roleOrg']?.toString(), + dateAdhesion: json['dateAdhesion']?.toString(), + ); + } + + @override + List get props => [ + organisationId, + nom, + nomCourt, + typeOrganisation, + categorieType, + modulesActifs, + statut, + statutMembre, + roleOrg, + dateAdhesion, + ]; +} diff --git a/lib/features/organizations/presentation/pages/org_selector_page.dart b/lib/features/organizations/presentation/pages/org_selector_page.dart new file mode 100644 index 0000000..25b9b9d --- /dev/null +++ b/lib/features/organizations/presentation/pages/org_selector_page.dart @@ -0,0 +1,384 @@ +/// Page de sélection d'organisation pour les membres multi-org. +/// +/// S'affiche après la connexion si le membre appartient à plusieurs organisations, +/// ou accessible depuis le profil pour changer d'organisation active. +library org_selector_page; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../bloc/org_switcher_bloc.dart'; +import '../../data/models/org_switcher_entry.dart'; + +class OrgSelectorPage extends StatefulWidget { + /// Si true, la page ne peut pas être ignorée (premier choix obligatoire). + final bool required; + + const OrgSelectorPage({super.key, this.required = false}); + + @override + State createState() => _OrgSelectorPageState(); +} + +class _OrgSelectorPageState extends State { + @override + void initState() { + super.initState(); + context.read().add(const OrgSwitcherLoadRequested()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Choisir une organisation'), + automaticallyImplyLeading: !widget.required, + elevation: 0, + ), + body: BlocConsumer( + listener: (context, state) { + if (state is OrgSwitcherLoaded && widget.required && state.active != null) { + // Une org a été auto-sélectionnée, on peut continuer + } + }, + builder: (context, state) { + if (state is OrgSwitcherLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (state is OrgSwitcherError) { + return _ErrorView( + message: state.message, + onRetry: () => context + .read() + .add(const OrgSwitcherLoadRequested()), + ); + } + if (state is OrgSwitcherLoaded) { + if (state.organisations.isEmpty) { + return const _EmptyView(); + } + return _OrgList( + organisations: state.organisations, + active: state.active, + onSelect: (org) { + context + .read() + .add(OrgSwitcherSelectRequested(org)); + Navigator.of(context).pop(org); + }, + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} + +// ─── Widgets privés ────────────────────────────────────────────────────────── + +class _OrgList extends StatelessWidget { + final List organisations; + final OrgSwitcherEntry? active; + final ValueChanged onSelect; + + const _OrgList({ + required this.organisations, + required this.active, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Sélectionnez l\'organisation dans laquelle vous souhaitez travailler.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: organisations.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final org = organisations[index]; + final isActive = active?.organisationId == org.organisationId; + return _OrgCard( + org: org, + isActive: isActive, + onTap: () => onSelect(org), + ); + }, + ), + ), + ], + ); + } +} + +class _OrgCard extends StatelessWidget { + final OrgSwitcherEntry org; + final bool isActive; + final VoidCallback onTap; + + const _OrgCard({ + required this.org, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + elevation: isActive ? 3 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: isActive + ? BorderSide(color: colorScheme.primary, width: 2) + : BorderSide.none, + ), + color: isActive + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : colorScheme.surface, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Icône / avatar + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isActive + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + org.nom.isNotEmpty ? org.nom[0].toUpperCase() : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + color: isActive ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + // Informations + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + org.libelleCourt, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isActive ? colorScheme.primary : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + _Chip(org.typeOrganisation), + if (org.statutMembre != null) ...[ + const SizedBox(width: 6), + _Chip( + org.statutMembre!, + color: org.statutMembre == 'ACTIF' + ? Colors.green.shade700 + : Colors.orange.shade700, + ), + ], + ], + ), + if (org.roleOrg != null) ...[ + const SizedBox(height: 2), + Text( + org.roleOrg!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (isActive) + Icon(Icons.check_circle, color: colorScheme.primary, size: 24), + ], + ), + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + final String label; + final Color? color; + + const _Chip(this.label, {this.color}); + + @override + Widget build(BuildContext context) { + final effectiveColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: effectiveColor, + ), + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off, size: 56, color: Colors.grey), + const SizedBox(height: 16), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + ], + ), + ), + ); + } +} + +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.business_outlined, size: 56, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Vous n\'êtes membre d\'aucune organisation active.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +/// Widget compact pour afficher/changer l'org active dans une AppBar ou drawer. +/// +/// Usage: +/// ```dart +/// OrgSwitcherBadge(onTap: () => _openOrgSelector(context)) +/// ``` +class OrgSwitcherBadge extends StatelessWidget { + final OrgSwitcherEntry? activeOrg; + final VoidCallback onTap; + + const OrgSwitcherBadge({ + super.key, + required this.activeOrg, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final label = activeOrg?.libelleCourt ?? 'Choisir organisation'; + + return GestureDetector( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.business, size: 16, color: colorScheme.primary), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Icon(Icons.arrow_drop_down, size: 18, color: colorScheme.primary), + ], + ), + ), + ); + } +} + +/// Ouvre [OrgSelectorPage] comme bottom sheet modal. +/// +/// Retourne l'[OrgSwitcherEntry] sélectionnée ou null si annulé. +Future showOrgSelector( + BuildContext context, { + bool required = false, +}) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: context.read(), + child: OrgSelectorPage(required: required), + ), + ), + ); +} diff --git a/lib/features/organizations/presentation/pages/organizations_page.dart b/lib/features/organizations/presentation/pages/organizations_page.dart index cad9373..15bf4df 100644 --- a/lib/features/organizations/presentation/pages/organizations_page.dart +++ b/lib/features/organizations/presentation/pages/organizations_page.dart @@ -605,6 +605,12 @@ class _OrganizationsPageState extends State with TickerProvid return _buildEmptyState(); } + // Vérifier le rôle une seule fois pour toute la liste (UI-02) + final authState = context.read().state; + final canManageOrgs = authState is AuthAuthenticated && + (authState.effectiveRole == UserRole.superAdmin || + authState.effectiveRole == UserRole.orgAdmin); + return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -617,9 +623,9 @@ class _OrganizationsPageState extends State with TickerProvid child: OrganizationCard( organization: org, onTap: () => _showOrganizationDetails(org), - onEdit: () => _showEditOrganizationDialog(org), - onDelete: () => _confirmDeleteOrganization(org), - showActions: true, + onEdit: canManageOrgs ? () => _showEditOrganizationDialog(org) : null, + onDelete: canManageOrgs ? () => _confirmDeleteOrganization(org) : null, + showActions: canManageOrgs, ), ); }, diff --git a/lib/features/profile/data/repositories/profile_repository.dart b/lib/features/profile/data/repositories/profile_repository.dart index e4b013d..129742e 100644 --- a/lib/features/profile/data/repositories/profile_repository.dart +++ b/lib/features/profile/data/repositories/profile_repository.dart @@ -175,10 +175,10 @@ class ProfileRepositoryImpl implements IProfileRepository { @override Future changePassword(String id, String oldPassword, String newPassword) async { try { - // Appel direct à l'API Keycloak pour changer le mot de passe - // Via l'endpoint /api/auth/change-password qui proxy vers Keycloak + // Changement de mot de passe via l'API UnionFlow + // Endpoint: POST /api/membres/auth/change-password (direct Keycloak Admin) final response = await _apiClient.post( - '/api/auth/change-password', + '/api/membres/auth/change-password', data: { 'userId': id, 'oldPassword': oldPassword, diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 55e805c..858f6f7 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -17,6 +17,8 @@ import '../../../../core/l10n/locale_provider.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../authentication/presentation/bloc/auth_bloc.dart'; import '../../../members/data/models/membre_complete_model.dart'; +import '../../../organizations/bloc/org_switcher_bloc.dart'; +import '../../../organizations/presentation/pages/org_selector_page.dart'; import '../../../settings/presentation/pages/language_settings_page.dart'; import '../../../settings/presentation/pages/privacy_settings_page.dart'; import '../../../settings/presentation/pages/feedback_page.dart'; @@ -309,6 +311,31 @@ class _ProfilePageState extends State _buildStatItem('ORG', orgValue), ], ), + // Sélecteur d'organisation (multi-org) + BlocBuilder( + builder: (context, orgState) { + if (orgState is! OrgSwitcherLoaded || + orgState.organisations.length <= 1) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Center( + child: OrgSwitcherBadge( + activeOrg: orgState.active, + onTap: () async { + final selected = await showOrgSelector(context); + if (selected != null && context.mounted) { + context + .read() + .add(OrgSwitcherSelectRequested(selected)); + } + }, + ), + ), + ); + }, + ), ], ), ); diff --git a/lib/features/profile/presentation/pages/profile_page_wrapper.dart b/lib/features/profile/presentation/pages/profile_page_wrapper.dart index 842d92c..c3c0892 100644 --- a/lib/features/profile/presentation/pages/profile_page_wrapper.dart +++ b/lib/features/profile/presentation/pages/profile_page_wrapper.dart @@ -3,17 +3,26 @@ library profile_page_wrapper; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/di/injection_container.dart'; +import '../../../organizations/bloc/org_switcher_bloc.dart'; import '../bloc/profile_bloc.dart'; import 'profile_page.dart'; -/// Wrapper qui fournit le ProfileBloc à la ProfilePage +/// Wrapper qui fournit le ProfileBloc et OrgSwitcherBloc à la ProfilePage. class ProfilePageWrapper extends StatelessWidget { const ProfilePageWrapper({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => sl(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => sl(), + ), + BlocProvider( + create: (_) => sl() + ..add(const OrgSwitcherLoadRequested()), + ), + ], child: const ProfilePage(), ); } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..1cda713 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,1435 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_fr.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('fr') + ]; + + /// Titre de l'application + /// + /// In fr, this message translates to: + /// **'UnionFlow'** + String get appTitle; + + /// No description provided for @login. + /// + /// In fr, this message translates to: + /// **'Connexion'** + String get login; + + /// No description provided for @logout. + /// + /// In fr, this message translates to: + /// **'Déconnexion'** + String get logout; + + /// No description provided for @email. + /// + /// In fr, this message translates to: + /// **'Email'** + String get email; + + /// No description provided for @password. + /// + /// In fr, this message translates to: + /// **'Mot de passe'** + String get password; + + /// No description provided for @forgotPassword. + /// + /// In fr, this message translates to: + /// **'Mot de passe oublié ?'** + String get forgotPassword; + + /// No description provided for @rememberMe. + /// + /// In fr, this message translates to: + /// **'Se souvenir de moi'** + String get rememberMe; + + /// No description provided for @signIn. + /// + /// In fr, this message translates to: + /// **'Se connecter'** + String get signIn; + + /// No description provided for @signUp. + /// + /// In fr, this message translates to: + /// **'S\'inscrire'** + String get signUp; + + /// No description provided for @welcome. + /// + /// In fr, this message translates to: + /// **'Bienvenue'** + String get welcome; + + /// No description provided for @welcomeBack. + /// + /// In fr, this message translates to: + /// **'Bon retour'** + String get welcomeBack; + + /// No description provided for @dashboard. + /// + /// In fr, this message translates to: + /// **'Tableau de bord'** + String get dashboard; + + /// No description provided for @members. + /// + /// In fr, this message translates to: + /// **'Membres'** + String get members; + + /// No description provided for @events. + /// + /// In fr, this message translates to: + /// **'Événements'** + String get events; + + /// No description provided for @organisations. + /// + /// In fr, this message translates to: + /// **'Organisations'** + String get organisations; + + /// No description provided for @cotisations. + /// + /// In fr, this message translates to: + /// **'Cotisations'** + String get cotisations; + + /// No description provided for @solidarity. + /// + /// In fr, this message translates to: + /// **'Solidarité'** + String get solidarity; + + /// No description provided for @reports. + /// + /// In fr, this message translates to: + /// **'Rapports'** + String get reports; + + /// No description provided for @notifications. + /// + /// In fr, this message translates to: + /// **'Notifications'** + String get notifications; + + /// No description provided for @profile. + /// + /// In fr, this message translates to: + /// **'Profil'** + String get profile; + + /// No description provided for @settings. + /// + /// In fr, this message translates to: + /// **'Paramètres'** + String get settings; + + /// No description provided for @more. + /// + /// In fr, this message translates to: + /// **'Plus'** + String get more; + + /// No description provided for @search. + /// + /// In fr, this message translates to: + /// **'Rechercher'** + String get search; + + /// No description provided for @filter. + /// + /// In fr, this message translates to: + /// **'Filtrer'** + String get filter; + + /// No description provided for @sort. + /// + /// In fr, this message translates to: + /// **'Trier'** + String get sort; + + /// No description provided for @create. + /// + /// In fr, this message translates to: + /// **'Créer'** + String get create; + + /// No description provided for @add. + /// + /// In fr, this message translates to: + /// **'Ajouter'** + String get add; + + /// No description provided for @edit. + /// + /// In fr, this message translates to: + /// **'Modifier'** + String get edit; + + /// No description provided for @delete. + /// + /// In fr, this message translates to: + /// **'Supprimer'** + String get delete; + + /// No description provided for @save. + /// + /// In fr, this message translates to: + /// **'Enregistrer'** + String get save; + + /// No description provided for @cancel. + /// + /// In fr, this message translates to: + /// **'Annuler'** + String get cancel; + + /// No description provided for @confirm. + /// + /// In fr, this message translates to: + /// **'Confirmer'** + String get confirm; + + /// No description provided for @close. + /// + /// In fr, this message translates to: + /// **'Fermer'** + String get close; + + /// No description provided for @back. + /// + /// In fr, this message translates to: + /// **'Retour'** + String get back; + + /// No description provided for @next. + /// + /// In fr, this message translates to: + /// **'Suivant'** + String get next; + + /// No description provided for @previous. + /// + /// In fr, this message translates to: + /// **'Précédent'** + String get previous; + + /// No description provided for @finish. + /// + /// In fr, this message translates to: + /// **'Terminer'** + String get finish; + + /// No description provided for @retry. + /// + /// In fr, this message translates to: + /// **'Réessayer'** + String get retry; + + /// No description provided for @refresh. + /// + /// In fr, this message translates to: + /// **'Actualiser'** + String get refresh; + + /// No description provided for @export. + /// + /// In fr, this message translates to: + /// **'Exporter'** + String get export; + + /// No description provided for @import. + /// + /// In fr, this message translates to: + /// **'Importer'** + String get import; + + /// No description provided for @download. + /// + /// In fr, this message translates to: + /// **'Télécharger'** + String get download; + + /// No description provided for @upload. + /// + /// In fr, this message translates to: + /// **'Téléverser'** + String get upload; + + /// No description provided for @share. + /// + /// In fr, this message translates to: + /// **'Partager'** + String get share; + + /// No description provided for @print. + /// + /// In fr, this message translates to: + /// **'Imprimer'** + String get print; + + /// No description provided for @loading. + /// + /// In fr, this message translates to: + /// **'Chargement...'** + String get loading; + + /// No description provided for @loadingData. + /// + /// In fr, this message translates to: + /// **'Chargement des données...'** + String get loadingData; + + /// No description provided for @initializing. + /// + /// In fr, this message translates to: + /// **'Initialisation...'** + String get initializing; + + /// No description provided for @updating. + /// + /// In fr, this message translates to: + /// **'Mise à jour...'** + String get updating; + + /// No description provided for @saving. + /// + /// In fr, this message translates to: + /// **'Enregistrement...'** + String get saving; + + /// No description provided for @deleting. + /// + /// In fr, this message translates to: + /// **'Suppression...'** + String get deleting; + + /// No description provided for @processing. + /// + /// In fr, this message translates to: + /// **'Traitement...'** + String get processing; + + /// No description provided for @error. + /// + /// In fr, this message translates to: + /// **'Erreur'** + String get error; + + /// No description provided for @errorOccurred. + /// + /// In fr, this message translates to: + /// **'Une erreur s\'est produite'** + String get errorOccurred; + + /// No description provided for @errorUnexpected. + /// + /// In fr, this message translates to: + /// **'Une erreur inattendue s\'est produite.'** + String get errorUnexpected; + + /// No description provided for @errorNetwork. + /// + /// In fr, this message translates to: + /// **'Erreur de connexion. Vérifiez votre connexion internet.'** + String get errorNetwork; + + /// No description provided for @errorServer. + /// + /// In fr, this message translates to: + /// **'Erreur serveur. Veuillez réessayer plus tard.'** + String get errorServer; + + /// No description provided for @errorAuth. + /// + /// In fr, this message translates to: + /// **'Non authentifié. Veuillez vous reconnecter.'** + String get errorAuth; + + /// No description provided for @errorPermission. + /// + /// In fr, this message translates to: + /// **'Accès refusé. Vous n\'avez pas les permissions nécessaires.'** + String get errorPermission; + + /// No description provided for @errorNotFound. + /// + /// In fr, this message translates to: + /// **'Ressource non trouvée.'** + String get errorNotFound; + + /// No description provided for @errorValidation. + /// + /// In fr, this message translates to: + /// **'Données invalides. Vérifiez les informations saisies.'** + String get errorValidation; + + /// No description provided for @errorTimeout. + /// + /// In fr, this message translates to: + /// **'Délai d\'attente dépassé.'** + String get errorTimeout; + + /// No description provided for @success. + /// + /// In fr, this message translates to: + /// **'Succès'** + String get success; + + /// No description provided for @successSaved. + /// + /// In fr, this message translates to: + /// **'Enregistré avec succès'** + String get successSaved; + + /// No description provided for @successDeleted. + /// + /// In fr, this message translates to: + /// **'Supprimé avec succès'** + String get successDeleted; + + /// No description provided for @successUpdated. + /// + /// In fr, this message translates to: + /// **'Mis à jour avec succès'** + String get successUpdated; + + /// No description provided for @successCreated. + /// + /// In fr, this message translates to: + /// **'Créé avec succès'** + String get successCreated; + + /// No description provided for @warning. + /// + /// In fr, this message translates to: + /// **'Attention'** + String get warning; + + /// No description provided for @info. + /// + /// In fr, this message translates to: + /// **'Information'** + String get info; + + /// No description provided for @noData. + /// + /// In fr, this message translates to: + /// **'Aucune donnée disponible'** + String get noData; + + /// No description provided for @noResults. + /// + /// In fr, this message translates to: + /// **'Aucun résultat trouvé'** + String get noResults; + + /// No description provided for @noConnection. + /// + /// In fr, this message translates to: + /// **'Pas de connexion'** + String get noConnection; + + /// No description provided for @emptyList. + /// + /// In fr, this message translates to: + /// **'La liste est vide'** + String get emptyList; + + /// No description provided for @yes. + /// + /// In fr, this message translates to: + /// **'Oui'** + String get yes; + + /// No description provided for @no. + /// + /// In fr, this message translates to: + /// **'Non'** + String get no; + + /// No description provided for @ok. + /// + /// In fr, this message translates to: + /// **'OK'** + String get ok; + + /// No description provided for @all. + /// + /// In fr, this message translates to: + /// **'Tous'** + String get all; + + /// No description provided for @none. + /// + /// In fr, this message translates to: + /// **'Aucun'** + String get none; + + /// No description provided for @name. + /// + /// In fr, this message translates to: + /// **'Nom'** + String get name; + + /// No description provided for @firstName. + /// + /// In fr, this message translates to: + /// **'Prénom'** + String get firstName; + + /// No description provided for @lastName. + /// + /// In fr, this message translates to: + /// **'Nom de famille'** + String get lastName; + + /// No description provided for @fullName. + /// + /// In fr, this message translates to: + /// **'Nom complet'** + String get fullName; + + /// No description provided for @phone. + /// + /// In fr, this message translates to: + /// **'Téléphone'** + String get phone; + + /// No description provided for @address. + /// + /// In fr, this message translates to: + /// **'Adresse'** + String get address; + + /// No description provided for @city. + /// + /// In fr, this message translates to: + /// **'Ville'** + String get city; + + /// No description provided for @postalCode. + /// + /// In fr, this message translates to: + /// **'Code postal'** + String get postalCode; + + /// No description provided for @country. + /// + /// In fr, this message translates to: + /// **'Pays'** + String get country; + + /// No description provided for @region. + /// + /// In fr, this message translates to: + /// **'Région'** + String get region; + + /// No description provided for @birthDate. + /// + /// In fr, this message translates to: + /// **'Date de naissance'** + String get birthDate; + + /// No description provided for @gender. + /// + /// In fr, this message translates to: + /// **'Genre'** + String get gender; + + /// No description provided for @profession. + /// + /// In fr, this message translates to: + /// **'Profession'** + String get profession; + + /// No description provided for @nationality. + /// + /// In fr, this message translates to: + /// **'Nationalité'** + String get nationality; + + /// No description provided for @status. + /// + /// In fr, this message translates to: + /// **'Statut'** + String get status; + + /// No description provided for @statusActive. + /// + /// In fr, this message translates to: + /// **'Actif'** + String get statusActive; + + /// No description provided for @statusInactive. + /// + /// In fr, this message translates to: + /// **'Inactif'** + String get statusInactive; + + /// No description provided for @statusSuspended. + /// + /// In fr, this message translates to: + /// **'Suspendu'** + String get statusSuspended; + + /// No description provided for @statusPending. + /// + /// In fr, this message translates to: + /// **'En attente'** + String get statusPending; + + /// No description provided for @statusConfirmed. + /// + /// In fr, this message translates to: + /// **'Confirmé'** + String get statusConfirmed; + + /// No description provided for @statusCancelled. + /// + /// In fr, this message translates to: + /// **'Annulé'** + String get statusCancelled; + + /// No description provided for @statusPostponed. + /// + /// In fr, this message translates to: + /// **'Reporté'** + String get statusPostponed; + + /// No description provided for @statusDraft. + /// + /// In fr, this message translates to: + /// **'Brouillon'** + String get statusDraft; + + /// No description provided for @role. + /// + /// In fr, this message translates to: + /// **'Rôle'** + String get role; + + /// No description provided for @roleSuperAdmin. + /// + /// In fr, this message translates to: + /// **'Super Administrateur'** + String get roleSuperAdmin; + + /// No description provided for @roleOrgAdmin. + /// + /// In fr, this message translates to: + /// **'Administrateur Org'** + String get roleOrgAdmin; + + /// No description provided for @roleModerator. + /// + /// In fr, this message translates to: + /// **'Modérateur'** + String get roleModerator; + + /// No description provided for @roleActiveMember. + /// + /// In fr, this message translates to: + /// **'Membre Actif'** + String get roleActiveMember; + + /// No description provided for @roleSimpleMember. + /// + /// In fr, this message translates to: + /// **'Membre Simple'** + String get roleSimpleMember; + + /// No description provided for @roleVisitor. + /// + /// In fr, this message translates to: + /// **'Visiteur'** + String get roleVisitor; + + /// No description provided for @type. + /// + /// In fr, this message translates to: + /// **'Type'** + String get type; + + /// No description provided for @typeOfficial. + /// + /// In fr, this message translates to: + /// **'Officiel'** + String get typeOfficial; + + /// No description provided for @typeSocial. + /// + /// In fr, this message translates to: + /// **'Social'** + String get typeSocial; + + /// No description provided for @typeTraining. + /// + /// In fr, this message translates to: + /// **'Formation'** + String get typeTraining; + + /// No description provided for @typeSolidarity. + /// + /// In fr, this message translates to: + /// **'Solidarité'** + String get typeSolidarity; + + /// No description provided for @typeOther. + /// + /// In fr, this message translates to: + /// **'Autre'** + String get typeOther; + + /// No description provided for @priority. + /// + /// In fr, this message translates to: + /// **'Priorité'** + String get priority; + + /// No description provided for @priorityLow. + /// + /// In fr, this message translates to: + /// **'Basse'** + String get priorityLow; + + /// No description provided for @priorityMedium. + /// + /// In fr, this message translates to: + /// **'Moyenne'** + String get priorityMedium; + + /// No description provided for @priorityHigh. + /// + /// In fr, this message translates to: + /// **'Haute'** + String get priorityHigh; + + /// No description provided for @date. + /// + /// In fr, this message translates to: + /// **'Date'** + String get date; + + /// No description provided for @startDate. + /// + /// In fr, this message translates to: + /// **'Date de début'** + String get startDate; + + /// No description provided for @endDate. + /// + /// In fr, this message translates to: + /// **'Date de fin'** + String get endDate; + + /// No description provided for @createdAt. + /// + /// In fr, this message translates to: + /// **'Créé le'** + String get createdAt; + + /// No description provided for @updatedAt. + /// + /// In fr, this message translates to: + /// **'Modifié le'** + String get updatedAt; + + /// No description provided for @lastActivity. + /// + /// In fr, this message translates to: + /// **'Dernière activité'** + String get lastActivity; + + /// No description provided for @description. + /// + /// In fr, this message translates to: + /// **'Description'** + String get description; + + /// No description provided for @details. + /// + /// In fr, this message translates to: + /// **'Détails'** + String get details; + + /// No description provided for @location. + /// + /// In fr, this message translates to: + /// **'Lieu'** + String get location; + + /// No description provided for @organizer. + /// + /// In fr, this message translates to: + /// **'Organisateur'** + String get organizer; + + /// No description provided for @participants. + /// + /// In fr, this message translates to: + /// **'Participants'** + String get participants; + + /// No description provided for @maxParticipants. + /// + /// In fr, this message translates to: + /// **'Participants max'** + String get maxParticipants; + + /// No description provided for @currentParticipants. + /// + /// In fr, this message translates to: + /// **'Participants actuels'** + String get currentParticipants; + + /// No description provided for @availableSpots. + /// + /// In fr, this message translates to: + /// **'Places disponibles'** + String get availableSpots; + + /// No description provided for @full. + /// + /// In fr, this message translates to: + /// **'Complet'** + String get full; + + /// No description provided for @cost. + /// + /// In fr, this message translates to: + /// **'Coût'** + String get cost; + + /// No description provided for @free. + /// + /// In fr, this message translates to: + /// **'Gratuit'** + String get free; + + /// No description provided for @price. + /// + /// In fr, this message translates to: + /// **'Prix'** + String get price; + + /// No description provided for @currency. + /// + /// In fr, this message translates to: + /// **'Devise'** + String get currency; + + /// No description provided for @membersManagement. + /// + /// In fr, this message translates to: + /// **'Gestion des Membres'** + String get membersManagement; + + /// No description provided for @membersTotal. + /// + /// In fr, this message translates to: + /// **'{count} membres au total'** + String membersTotal(int count); + + /// No description provided for @membersActive. + /// + /// In fr, this message translates to: + /// **'Actifs'** + String get membersActive; + + /// No description provided for @membersInactive. + /// + /// In fr, this message translates to: + /// **'Inactifs'** + String get membersInactive; + + /// No description provided for @membersPending. + /// + /// In fr, this message translates to: + /// **'En attente'** + String get membersPending; + + /// No description provided for @addMember. + /// + /// In fr, this message translates to: + /// **'Ajouter un membre'** + String get addMember; + + /// No description provided for @editMember. + /// + /// In fr, this message translates to: + /// **'Modifier le membre'** + String get editMember; + + /// No description provided for @deleteMember. + /// + /// In fr, this message translates to: + /// **'Supprimer le membre'** + String get deleteMember; + + /// No description provided for @memberDetails. + /// + /// In fr, this message translates to: + /// **'Détails du membre'** + String get memberDetails; + + /// No description provided for @searchMembers. + /// + /// In fr, this message translates to: + /// **'Rechercher un membre...'** + String get searchMembers; + + /// No description provided for @noMembersFound. + /// + /// In fr, this message translates to: + /// **'Aucun membre trouvé'** + String get noMembersFound; + + /// No description provided for @eventsManagement. + /// + /// In fr, this message translates to: + /// **'Gestion des Événements'** + String get eventsManagement; + + /// No description provided for @eventsTotal. + /// + /// In fr, this message translates to: + /// **'{count} événements au total'** + String eventsTotal(int count); + + /// No description provided for @eventsUpcoming. + /// + /// In fr, this message translates to: + /// **'À venir'** + String get eventsUpcoming; + + /// No description provided for @eventsOngoing. + /// + /// In fr, this message translates to: + /// **'En cours'** + String get eventsOngoing; + + /// No description provided for @eventsPast. + /// + /// In fr, this message translates to: + /// **'Passés'** + String get eventsPast; + + /// No description provided for @addEvent. + /// + /// In fr, this message translates to: + /// **'Ajouter un événement'** + String get addEvent; + + /// No description provided for @editEvent. + /// + /// In fr, this message translates to: + /// **'Modifier l\'événement'** + String get editEvent; + + /// No description provided for @deleteEvent. + /// + /// In fr, this message translates to: + /// **'Supprimer l\'événement'** + String get deleteEvent; + + /// No description provided for @eventDetails. + /// + /// In fr, this message translates to: + /// **'Détails de l\'événement'** + String get eventDetails; + + /// No description provided for @searchEvents. + /// + /// In fr, this message translates to: + /// **'Rechercher un événement...'** + String get searchEvents; + + /// No description provided for @noEventsFound. + /// + /// In fr, this message translates to: + /// **'Aucun événement trouvé'** + String get noEventsFound; + + /// No description provided for @calendar. + /// + /// In fr, this message translates to: + /// **'Calendrier'** + String get calendar; + + /// No description provided for @register. + /// + /// In fr, this message translates to: + /// **'S\'inscrire'** + String get register; + + /// No description provided for @unregister. + /// + /// In fr, this message translates to: + /// **'Se désinscrire'** + String get unregister; + + /// No description provided for @organisationsManagement. + /// + /// In fr, this message translates to: + /// **'Gestion des Organisations'** + String get organisationsManagement; + + /// No description provided for @organisationsTotal. + /// + /// In fr, this message translates to: + /// **'{count} organisations au total'** + String organisationsTotal(int count); + + /// No description provided for @addOrganisation. + /// + /// In fr, this message translates to: + /// **'Ajouter une organisation'** + String get addOrganisation; + + /// No description provided for @editOrganisation. + /// + /// In fr, this message translates to: + /// **'Modifier l\'organisation'** + String get editOrganisation; + + /// No description provided for @deleteOrganisation. + /// + /// In fr, this message translates to: + /// **'Supprimer l\'organisation'** + String get deleteOrganisation; + + /// No description provided for @organisationDetails. + /// + /// In fr, this message translates to: + /// **'Détails de l\'organisation'** + String get organisationDetails; + + /// No description provided for @searchOrganisations. + /// + /// In fr, this message translates to: + /// **'Rechercher une organisation...'** + String get searchOrganisations; + + /// No description provided for @noOrganisationsFound. + /// + /// In fr, this message translates to: + /// **'Aucune organisation trouvée'** + String get noOrganisationsFound; + + /// No description provided for @cotisationsManagement. + /// + /// In fr, this message translates to: + /// **'Gestion des Cotisations'** + String get cotisationsManagement; + + /// No description provided for @cotisationsTotal. + /// + /// In fr, this message translates to: + /// **'{count} cotisations au total'** + String cotisationsTotal(int count); + + /// No description provided for @cotisationPaid. + /// + /// In fr, this message translates to: + /// **'Payée'** + String get cotisationPaid; + + /// No description provided for @cotisationUnpaid. + /// + /// In fr, this message translates to: + /// **'Non payée'** + String get cotisationUnpaid; + + /// No description provided for @cotisationOverdue. + /// + /// In fr, this message translates to: + /// **'En retard'** + String get cotisationOverdue; + + /// No description provided for @addCotisation. + /// + /// In fr, this message translates to: + /// **'Ajouter une cotisation'** + String get addCotisation; + + /// No description provided for @editCotisation. + /// + /// In fr, this message translates to: + /// **'Modifier la cotisation'** + String get editCotisation; + + /// No description provided for @deleteCotisation. + /// + /// In fr, this message translates to: + /// **'Supprimer la cotisation'** + String get deleteCotisation; + + /// No description provided for @cotisationDetails. + /// + /// In fr, this message translates to: + /// **'Détails de la cotisation'** + String get cotisationDetails; + + /// No description provided for @searchCotisations. + /// + /// In fr, this message translates to: + /// **'Rechercher une cotisation...'** + String get searchCotisations; + + /// No description provided for @noCotisationsFound. + /// + /// In fr, this message translates to: + /// **'Aucune cotisation trouvée'** + String get noCotisationsFound; + + /// No description provided for @amount. + /// + /// In fr, this message translates to: + /// **'Montant'** + String get amount; + + /// No description provided for @dueDate. + /// + /// In fr, this message translates to: + /// **'Date d\'échéance'** + String get dueDate; + + /// No description provided for @paymentDate. + /// + /// In fr, this message translates to: + /// **'Date de paiement'** + String get paymentDate; + + /// No description provided for @paymentMethod. + /// + /// In fr, this message translates to: + /// **'Méthode de paiement'** + String get paymentMethod; + + /// No description provided for @statistics. + /// + /// In fr, this message translates to: + /// **'Statistiques'** + String get statistics; + + /// No description provided for @analytics. + /// + /// In fr, this message translates to: + /// **'Analytics'** + String get analytics; + + /// No description provided for @total. + /// + /// In fr, this message translates to: + /// **'Total'** + String get total; + + /// No description provided for @average. + /// + /// In fr, this message translates to: + /// **'Moyenne'** + String get average; + + /// No description provided for @percentage. + /// + /// In fr, this message translates to: + /// **'Pourcentage'** + String get percentage; + + /// No description provided for @viewList. + /// + /// In fr, this message translates to: + /// **'Vue liste'** + String get viewList; + + /// No description provided for @viewGrid. + /// + /// In fr, this message translates to: + /// **'Vue grille'** + String get viewGrid; + + /// No description provided for @viewCalendar. + /// + /// In fr, this message translates to: + /// **'Vue calendrier'** + String get viewCalendar; + + /// No description provided for @page. + /// + /// In fr, this message translates to: + /// **'Page'** + String get page; + + /// No description provided for @pageOf. + /// + /// In fr, this message translates to: + /// **'Page {current} sur {total}'** + String pageOf(int current, int total); + + /// No description provided for @language. + /// + /// In fr, this message translates to: + /// **'Langue'** + String get language; + + /// No description provided for @languageFrench. + /// + /// In fr, this message translates to: + /// **'Français'** + String get languageFrench; + + /// No description provided for @languageEnglish. + /// + /// In fr, this message translates to: + /// **'English'** + String get languageEnglish; + + /// No description provided for @theme. + /// + /// In fr, this message translates to: + /// **'Thème'** + String get theme; + + /// No description provided for @themeLight. + /// + /// In fr, this message translates to: + /// **'Clair'** + String get themeLight; + + /// No description provided for @themeDark. + /// + /// In fr, this message translates to: + /// **'Sombre'** + String get themeDark; + + /// No description provided for @themeSystem. + /// + /// In fr, this message translates to: + /// **'Système'** + String get themeSystem; + + /// No description provided for @version. + /// + /// In fr, this message translates to: + /// **'Version'** + String get version; + + /// No description provided for @about. + /// + /// In fr, this message translates to: + /// **'À propos'** + String get about; + + /// No description provided for @help. + /// + /// In fr, this message translates to: + /// **'Aide'** + String get help; + + /// No description provided for @support. + /// + /// In fr, this message translates to: + /// **'Support'** + String get support; + + /// No description provided for @termsOfService. + /// + /// In fr, this message translates to: + /// **'Conditions d\'utilisation'** + String get termsOfService; + + /// No description provided for @privacyPolicy. + /// + /// In fr, this message translates to: + /// **'Politique de confidentialité'** + String get privacyPolicy; + + /// No description provided for @confirmDelete. + /// + /// In fr, this message translates to: + /// **'Êtes-vous sûr de vouloir supprimer ?'** + String get confirmDelete; + + /// No description provided for @confirmLogout. + /// + /// In fr, this message translates to: + /// **'Êtes-vous sûr de vouloir vous déconnecter ?'** + String get confirmLogout; + + /// No description provided for @confirmCancel. + /// + /// In fr, this message translates to: + /// **'Êtes-vous sûr de vouloir annuler ?'** + String get confirmCancel; + + /// No description provided for @requiredField. + /// + /// In fr, this message translates to: + /// **'Ce champ est requis'** + String get requiredField; + + /// No description provided for @invalidEmail. + /// + /// In fr, this message translates to: + /// **'Email invalide'** + String get invalidEmail; + + /// No description provided for @invalidPhone. + /// + /// In fr, this message translates to: + /// **'Numéro de téléphone invalide'** + String get invalidPhone; + + /// No description provided for @invalidDate. + /// + /// In fr, this message translates to: + /// **'Date invalide'** + String get invalidDate; + + /// No description provided for @passwordTooShort. + /// + /// In fr, this message translates to: + /// **'Le mot de passe est trop court'** + String get passwordTooShort; + + /// No description provided for @passwordsDoNotMatch. + /// + /// In fr, this message translates to: + /// **'Les mots de passe ne correspondent pas'** + String get passwordsDoNotMatch; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'fr'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'fr': + return AppLocalizationsFr(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..b571c25 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,673 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'UnionFlow'; + + @override + String get login => 'Login'; + + @override + String get logout => 'Logout'; + + @override + String get email => 'Email'; + + @override + String get password => 'Password'; + + @override + String get forgotPassword => 'Forgot password?'; + + @override + String get rememberMe => 'Remember me'; + + @override + String get signIn => 'Sign in'; + + @override + String get signUp => 'Sign up'; + + @override + String get welcome => 'Welcome'; + + @override + String get welcomeBack => 'Welcome back'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get members => 'Members'; + + @override + String get events => 'Events'; + + @override + String get organisations => 'Organizations'; + + @override + String get cotisations => 'Contributions'; + + @override + String get solidarity => 'Solidarity'; + + @override + String get reports => 'Reports'; + + @override + String get notifications => 'Notifications'; + + @override + String get profile => 'Profile'; + + @override + String get settings => 'Settings'; + + @override + String get more => 'More'; + + @override + String get search => 'Search'; + + @override + String get filter => 'Filter'; + + @override + String get sort => 'Sort'; + + @override + String get create => 'Create'; + + @override + String get add => 'Add'; + + @override + String get edit => 'Edit'; + + @override + String get delete => 'Delete'; + + @override + String get save => 'Save'; + + @override + String get cancel => 'Cancel'; + + @override + String get confirm => 'Confirm'; + + @override + String get close => 'Close'; + + @override + String get back => 'Back'; + + @override + String get next => 'Next'; + + @override + String get previous => 'Previous'; + + @override + String get finish => 'Finish'; + + @override + String get retry => 'Retry'; + + @override + String get refresh => 'Refresh'; + + @override + String get export => 'Export'; + + @override + String get import => 'Import'; + + @override + String get download => 'Download'; + + @override + String get upload => 'Upload'; + + @override + String get share => 'Share'; + + @override + String get print => 'Print'; + + @override + String get loading => 'Loading...'; + + @override + String get loadingData => 'Loading data...'; + + @override + String get initializing => 'Initializing...'; + + @override + String get updating => 'Updating...'; + + @override + String get saving => 'Saving...'; + + @override + String get deleting => 'Deleting...'; + + @override + String get processing => 'Processing...'; + + @override + String get error => 'Error'; + + @override + String get errorOccurred => 'An error occurred'; + + @override + String get errorUnexpected => 'An unexpected error occurred.'; + + @override + String get errorNetwork => + 'Connection error. Check your internet connection.'; + + @override + String get errorServer => 'Server error. Please try again later.'; + + @override + String get errorAuth => 'Not authenticated. Please log in again.'; + + @override + String get errorPermission => + 'Access denied. You don\'t have the necessary permissions.'; + + @override + String get errorNotFound => 'Resource not found.'; + + @override + String get errorValidation => 'Invalid data. Check the information entered.'; + + @override + String get errorTimeout => 'Request timeout.'; + + @override + String get success => 'Success'; + + @override + String get successSaved => 'Saved successfully'; + + @override + String get successDeleted => 'Deleted successfully'; + + @override + String get successUpdated => 'Updated successfully'; + + @override + String get successCreated => 'Created successfully'; + + @override + String get warning => 'Warning'; + + @override + String get info => 'Information'; + + @override + String get noData => 'No data available'; + + @override + String get noResults => 'No results found'; + + @override + String get noConnection => 'No connection'; + + @override + String get emptyList => 'The list is empty'; + + @override + String get yes => 'Yes'; + + @override + String get no => 'No'; + + @override + String get ok => 'OK'; + + @override + String get all => 'All'; + + @override + String get none => 'None'; + + @override + String get name => 'Name'; + + @override + String get firstName => 'First name'; + + @override + String get lastName => 'Last name'; + + @override + String get fullName => 'Full name'; + + @override + String get phone => 'Phone'; + + @override + String get address => 'Address'; + + @override + String get city => 'City'; + + @override + String get postalCode => 'Postal code'; + + @override + String get country => 'Country'; + + @override + String get region => 'Region'; + + @override + String get birthDate => 'Birth date'; + + @override + String get gender => 'Gender'; + + @override + String get profession => 'Profession'; + + @override + String get nationality => 'Nationality'; + + @override + String get status => 'Status'; + + @override + String get statusActive => 'Active'; + + @override + String get statusInactive => 'Inactive'; + + @override + String get statusSuspended => 'Suspended'; + + @override + String get statusPending => 'Pending'; + + @override + String get statusConfirmed => 'Confirmed'; + + @override + String get statusCancelled => 'Cancelled'; + + @override + String get statusPostponed => 'Postponed'; + + @override + String get statusDraft => 'Draft'; + + @override + String get role => 'Role'; + + @override + String get roleSuperAdmin => 'Super Admin'; + + @override + String get roleOrgAdmin => 'Org Admin'; + + @override + String get roleModerator => 'Moderator'; + + @override + String get roleActiveMember => 'Active Member'; + + @override + String get roleSimpleMember => 'Simple Member'; + + @override + String get roleVisitor => 'Visitor'; + + @override + String get type => 'Type'; + + @override + String get typeOfficial => 'Official'; + + @override + String get typeSocial => 'Social'; + + @override + String get typeTraining => 'Training'; + + @override + String get typeSolidarity => 'Solidarity'; + + @override + String get typeOther => 'Other'; + + @override + String get priority => 'Priority'; + + @override + String get priorityLow => 'Low'; + + @override + String get priorityMedium => 'Medium'; + + @override + String get priorityHigh => 'High'; + + @override + String get date => 'Date'; + + @override + String get startDate => 'Start date'; + + @override + String get endDate => 'End date'; + + @override + String get createdAt => 'Created at'; + + @override + String get updatedAt => 'Updated at'; + + @override + String get lastActivity => 'Last activity'; + + @override + String get description => 'Description'; + + @override + String get details => 'Details'; + + @override + String get location => 'Location'; + + @override + String get organizer => 'Organizer'; + + @override + String get participants => 'Participants'; + + @override + String get maxParticipants => 'Max participants'; + + @override + String get currentParticipants => 'Current participants'; + + @override + String get availableSpots => 'Available spots'; + + @override + String get full => 'Full'; + + @override + String get cost => 'Cost'; + + @override + String get free => 'Free'; + + @override + String get price => 'Price'; + + @override + String get currency => 'Currency'; + + @override + String get membersManagement => 'Members Management'; + + @override + String membersTotal(int count) { + return '$count members total'; + } + + @override + String get membersActive => 'Active'; + + @override + String get membersInactive => 'Inactive'; + + @override + String get membersPending => 'Pending'; + + @override + String get addMember => 'Add member'; + + @override + String get editMember => 'Edit member'; + + @override + String get deleteMember => 'Delete member'; + + @override + String get memberDetails => 'Member details'; + + @override + String get searchMembers => 'Search member...'; + + @override + String get noMembersFound => 'No members found'; + + @override + String get eventsManagement => 'Events Management'; + + @override + String eventsTotal(int count) { + return '$count events total'; + } + + @override + String get eventsUpcoming => 'Upcoming'; + + @override + String get eventsOngoing => 'Ongoing'; + + @override + String get eventsPast => 'Past'; + + @override + String get addEvent => 'Add event'; + + @override + String get editEvent => 'Edit event'; + + @override + String get deleteEvent => 'Delete event'; + + @override + String get eventDetails => 'Event details'; + + @override + String get searchEvents => 'Search event...'; + + @override + String get noEventsFound => 'No events found'; + + @override + String get calendar => 'Calendar'; + + @override + String get register => 'Register'; + + @override + String get unregister => 'Unregister'; + + @override + String get organisationsManagement => 'Organizations Management'; + + @override + String organisationsTotal(int count) { + return '$count organizations total'; + } + + @override + String get addOrganisation => 'Add organization'; + + @override + String get editOrganisation => 'Edit organization'; + + @override + String get deleteOrganisation => 'Delete organization'; + + @override + String get organisationDetails => 'Organization details'; + + @override + String get searchOrganisations => 'Search organization...'; + + @override + String get noOrganisationsFound => 'No organizations found'; + + @override + String get cotisationsManagement => 'Contributions Management'; + + @override + String cotisationsTotal(int count) { + return '$count contributions total'; + } + + @override + String get cotisationPaid => 'Paid'; + + @override + String get cotisationUnpaid => 'Unpaid'; + + @override + String get cotisationOverdue => 'Overdue'; + + @override + String get addCotisation => 'Add contribution'; + + @override + String get editCotisation => 'Edit contribution'; + + @override + String get deleteCotisation => 'Delete contribution'; + + @override + String get cotisationDetails => 'Contribution details'; + + @override + String get searchCotisations => 'Search contribution...'; + + @override + String get noCotisationsFound => 'No contributions found'; + + @override + String get amount => 'Amount'; + + @override + String get dueDate => 'Due date'; + + @override + String get paymentDate => 'Payment date'; + + @override + String get paymentMethod => 'Payment method'; + + @override + String get statistics => 'Statistics'; + + @override + String get analytics => 'Analytics'; + + @override + String get total => 'Total'; + + @override + String get average => 'Average'; + + @override + String get percentage => 'Percentage'; + + @override + String get viewList => 'List view'; + + @override + String get viewGrid => 'Grid view'; + + @override + String get viewCalendar => 'Calendar view'; + + @override + String get page => 'Page'; + + @override + String pageOf(int current, int total) { + return 'Page $current of $total'; + } + + @override + String get language => 'Language'; + + @override + String get languageFrench => 'Français'; + + @override + String get languageEnglish => 'English'; + + @override + String get theme => 'Theme'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeSystem => 'System'; + + @override + String get version => 'Version'; + + @override + String get about => 'About'; + + @override + String get help => 'Help'; + + @override + String get support => 'Support'; + + @override + String get termsOfService => 'Terms of Service'; + + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get confirmDelete => 'Are you sure you want to delete?'; + + @override + String get confirmLogout => 'Are you sure you want to log out?'; + + @override + String get confirmCancel => 'Are you sure you want to cancel?'; + + @override + String get requiredField => 'This field is required'; + + @override + String get invalidEmail => 'Invalid email'; + + @override + String get invalidPhone => 'Invalid phone number'; + + @override + String get invalidDate => 'Invalid date'; + + @override + String get passwordTooShort => 'Password is too short'; + + @override + String get passwordsDoNotMatch => 'Passwords do not match'; +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..f5c035b --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,674 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class AppLocalizationsFr extends AppLocalizations { + AppLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get appTitle => 'UnionFlow'; + + @override + String get login => 'Connexion'; + + @override + String get logout => 'Déconnexion'; + + @override + String get email => 'Email'; + + @override + String get password => 'Mot de passe'; + + @override + String get forgotPassword => 'Mot de passe oublié ?'; + + @override + String get rememberMe => 'Se souvenir de moi'; + + @override + String get signIn => 'Se connecter'; + + @override + String get signUp => 'S\'inscrire'; + + @override + String get welcome => 'Bienvenue'; + + @override + String get welcomeBack => 'Bon retour'; + + @override + String get dashboard => 'Tableau de bord'; + + @override + String get members => 'Membres'; + + @override + String get events => 'Événements'; + + @override + String get organisations => 'Organisations'; + + @override + String get cotisations => 'Cotisations'; + + @override + String get solidarity => 'Solidarité'; + + @override + String get reports => 'Rapports'; + + @override + String get notifications => 'Notifications'; + + @override + String get profile => 'Profil'; + + @override + String get settings => 'Paramètres'; + + @override + String get more => 'Plus'; + + @override + String get search => 'Rechercher'; + + @override + String get filter => 'Filtrer'; + + @override + String get sort => 'Trier'; + + @override + String get create => 'Créer'; + + @override + String get add => 'Ajouter'; + + @override + String get edit => 'Modifier'; + + @override + String get delete => 'Supprimer'; + + @override + String get save => 'Enregistrer'; + + @override + String get cancel => 'Annuler'; + + @override + String get confirm => 'Confirmer'; + + @override + String get close => 'Fermer'; + + @override + String get back => 'Retour'; + + @override + String get next => 'Suivant'; + + @override + String get previous => 'Précédent'; + + @override + String get finish => 'Terminer'; + + @override + String get retry => 'Réessayer'; + + @override + String get refresh => 'Actualiser'; + + @override + String get export => 'Exporter'; + + @override + String get import => 'Importer'; + + @override + String get download => 'Télécharger'; + + @override + String get upload => 'Téléverser'; + + @override + String get share => 'Partager'; + + @override + String get print => 'Imprimer'; + + @override + String get loading => 'Chargement...'; + + @override + String get loadingData => 'Chargement des données...'; + + @override + String get initializing => 'Initialisation...'; + + @override + String get updating => 'Mise à jour...'; + + @override + String get saving => 'Enregistrement...'; + + @override + String get deleting => 'Suppression...'; + + @override + String get processing => 'Traitement...'; + + @override + String get error => 'Erreur'; + + @override + String get errorOccurred => 'Une erreur s\'est produite'; + + @override + String get errorUnexpected => 'Une erreur inattendue s\'est produite.'; + + @override + String get errorNetwork => + 'Erreur de connexion. Vérifiez votre connexion internet.'; + + @override + String get errorServer => 'Erreur serveur. Veuillez réessayer plus tard.'; + + @override + String get errorAuth => 'Non authentifié. Veuillez vous reconnecter.'; + + @override + String get errorPermission => + 'Accès refusé. Vous n\'avez pas les permissions nécessaires.'; + + @override + String get errorNotFound => 'Ressource non trouvée.'; + + @override + String get errorValidation => + 'Données invalides. Vérifiez les informations saisies.'; + + @override + String get errorTimeout => 'Délai d\'attente dépassé.'; + + @override + String get success => 'Succès'; + + @override + String get successSaved => 'Enregistré avec succès'; + + @override + String get successDeleted => 'Supprimé avec succès'; + + @override + String get successUpdated => 'Mis à jour avec succès'; + + @override + String get successCreated => 'Créé avec succès'; + + @override + String get warning => 'Attention'; + + @override + String get info => 'Information'; + + @override + String get noData => 'Aucune donnée disponible'; + + @override + String get noResults => 'Aucun résultat trouvé'; + + @override + String get noConnection => 'Pas de connexion'; + + @override + String get emptyList => 'La liste est vide'; + + @override + String get yes => 'Oui'; + + @override + String get no => 'Non'; + + @override + String get ok => 'OK'; + + @override + String get all => 'Tous'; + + @override + String get none => 'Aucun'; + + @override + String get name => 'Nom'; + + @override + String get firstName => 'Prénom'; + + @override + String get lastName => 'Nom de famille'; + + @override + String get fullName => 'Nom complet'; + + @override + String get phone => 'Téléphone'; + + @override + String get address => 'Adresse'; + + @override + String get city => 'Ville'; + + @override + String get postalCode => 'Code postal'; + + @override + String get country => 'Pays'; + + @override + String get region => 'Région'; + + @override + String get birthDate => 'Date de naissance'; + + @override + String get gender => 'Genre'; + + @override + String get profession => 'Profession'; + + @override + String get nationality => 'Nationalité'; + + @override + String get status => 'Statut'; + + @override + String get statusActive => 'Actif'; + + @override + String get statusInactive => 'Inactif'; + + @override + String get statusSuspended => 'Suspendu'; + + @override + String get statusPending => 'En attente'; + + @override + String get statusConfirmed => 'Confirmé'; + + @override + String get statusCancelled => 'Annulé'; + + @override + String get statusPostponed => 'Reporté'; + + @override + String get statusDraft => 'Brouillon'; + + @override + String get role => 'Rôle'; + + @override + String get roleSuperAdmin => 'Super Administrateur'; + + @override + String get roleOrgAdmin => 'Administrateur Org'; + + @override + String get roleModerator => 'Modérateur'; + + @override + String get roleActiveMember => 'Membre Actif'; + + @override + String get roleSimpleMember => 'Membre Simple'; + + @override + String get roleVisitor => 'Visiteur'; + + @override + String get type => 'Type'; + + @override + String get typeOfficial => 'Officiel'; + + @override + String get typeSocial => 'Social'; + + @override + String get typeTraining => 'Formation'; + + @override + String get typeSolidarity => 'Solidarité'; + + @override + String get typeOther => 'Autre'; + + @override + String get priority => 'Priorité'; + + @override + String get priorityLow => 'Basse'; + + @override + String get priorityMedium => 'Moyenne'; + + @override + String get priorityHigh => 'Haute'; + + @override + String get date => 'Date'; + + @override + String get startDate => 'Date de début'; + + @override + String get endDate => 'Date de fin'; + + @override + String get createdAt => 'Créé le'; + + @override + String get updatedAt => 'Modifié le'; + + @override + String get lastActivity => 'Dernière activité'; + + @override + String get description => 'Description'; + + @override + String get details => 'Détails'; + + @override + String get location => 'Lieu'; + + @override + String get organizer => 'Organisateur'; + + @override + String get participants => 'Participants'; + + @override + String get maxParticipants => 'Participants max'; + + @override + String get currentParticipants => 'Participants actuels'; + + @override + String get availableSpots => 'Places disponibles'; + + @override + String get full => 'Complet'; + + @override + String get cost => 'Coût'; + + @override + String get free => 'Gratuit'; + + @override + String get price => 'Prix'; + + @override + String get currency => 'Devise'; + + @override + String get membersManagement => 'Gestion des Membres'; + + @override + String membersTotal(int count) { + return '$count membres au total'; + } + + @override + String get membersActive => 'Actifs'; + + @override + String get membersInactive => 'Inactifs'; + + @override + String get membersPending => 'En attente'; + + @override + String get addMember => 'Ajouter un membre'; + + @override + String get editMember => 'Modifier le membre'; + + @override + String get deleteMember => 'Supprimer le membre'; + + @override + String get memberDetails => 'Détails du membre'; + + @override + String get searchMembers => 'Rechercher un membre...'; + + @override + String get noMembersFound => 'Aucun membre trouvé'; + + @override + String get eventsManagement => 'Gestion des Événements'; + + @override + String eventsTotal(int count) { + return '$count événements au total'; + } + + @override + String get eventsUpcoming => 'À venir'; + + @override + String get eventsOngoing => 'En cours'; + + @override + String get eventsPast => 'Passés'; + + @override + String get addEvent => 'Ajouter un événement'; + + @override + String get editEvent => 'Modifier l\'événement'; + + @override + String get deleteEvent => 'Supprimer l\'événement'; + + @override + String get eventDetails => 'Détails de l\'événement'; + + @override + String get searchEvents => 'Rechercher un événement...'; + + @override + String get noEventsFound => 'Aucun événement trouvé'; + + @override + String get calendar => 'Calendrier'; + + @override + String get register => 'S\'inscrire'; + + @override + String get unregister => 'Se désinscrire'; + + @override + String get organisationsManagement => 'Gestion des Organisations'; + + @override + String organisationsTotal(int count) { + return '$count organisations au total'; + } + + @override + String get addOrganisation => 'Ajouter une organisation'; + + @override + String get editOrganisation => 'Modifier l\'organisation'; + + @override + String get deleteOrganisation => 'Supprimer l\'organisation'; + + @override + String get organisationDetails => 'Détails de l\'organisation'; + + @override + String get searchOrganisations => 'Rechercher une organisation...'; + + @override + String get noOrganisationsFound => 'Aucune organisation trouvée'; + + @override + String get cotisationsManagement => 'Gestion des Cotisations'; + + @override + String cotisationsTotal(int count) { + return '$count cotisations au total'; + } + + @override + String get cotisationPaid => 'Payée'; + + @override + String get cotisationUnpaid => 'Non payée'; + + @override + String get cotisationOverdue => 'En retard'; + + @override + String get addCotisation => 'Ajouter une cotisation'; + + @override + String get editCotisation => 'Modifier la cotisation'; + + @override + String get deleteCotisation => 'Supprimer la cotisation'; + + @override + String get cotisationDetails => 'Détails de la cotisation'; + + @override + String get searchCotisations => 'Rechercher une cotisation...'; + + @override + String get noCotisationsFound => 'Aucune cotisation trouvée'; + + @override + String get amount => 'Montant'; + + @override + String get dueDate => 'Date d\'échéance'; + + @override + String get paymentDate => 'Date de paiement'; + + @override + String get paymentMethod => 'Méthode de paiement'; + + @override + String get statistics => 'Statistiques'; + + @override + String get analytics => 'Analytics'; + + @override + String get total => 'Total'; + + @override + String get average => 'Moyenne'; + + @override + String get percentage => 'Pourcentage'; + + @override + String get viewList => 'Vue liste'; + + @override + String get viewGrid => 'Vue grille'; + + @override + String get viewCalendar => 'Vue calendrier'; + + @override + String get page => 'Page'; + + @override + String pageOf(int current, int total) { + return 'Page $current sur $total'; + } + + @override + String get language => 'Langue'; + + @override + String get languageFrench => 'Français'; + + @override + String get languageEnglish => 'English'; + + @override + String get theme => 'Thème'; + + @override + String get themeLight => 'Clair'; + + @override + String get themeDark => 'Sombre'; + + @override + String get themeSystem => 'Système'; + + @override + String get version => 'Version'; + + @override + String get about => 'À propos'; + + @override + String get help => 'Aide'; + + @override + String get support => 'Support'; + + @override + String get termsOfService => 'Conditions d\'utilisation'; + + @override + String get privacyPolicy => 'Politique de confidentialité'; + + @override + String get confirmDelete => 'Êtes-vous sûr de vouloir supprimer ?'; + + @override + String get confirmLogout => 'Êtes-vous sûr de vouloir vous déconnecter ?'; + + @override + String get confirmCancel => 'Êtes-vous sûr de vouloir annuler ?'; + + @override + String get requiredField => 'Ce champ est requis'; + + @override + String get invalidEmail => 'Email invalide'; + + @override + String get invalidPhone => 'Numéro de téléphone invalide'; + + @override + String get invalidDate => 'Date invalide'; + + @override + String get passwordTooShort => 'Le mot de passe est trop court'; + + @override + String get passwordsDoNotMatch => 'Les mots de passe ne correspondent pas'; +} diff --git a/lib/main.dart b/lib/main.dart index 91682ce..bc5e375 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ library main; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app/app.dart'; import 'core/config/environment.dart'; import 'core/l10n/locale_provider.dart'; @@ -15,9 +16,10 @@ import 'core/di/injection.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); AppConfig.initialize(); + GoogleFonts.config.allowRuntimeFetching = !AppConfig.isProd; // Initialisation unique et automatique (DRY) - configureDependencies(); + await configureDependencies(); // Mode immersif et config système await _configureApp(); diff --git a/lib/shared/design_system/theme/app_theme_sophisticated.dart b/lib/shared/design_system/theme/app_theme_sophisticated.dart index 37a4da0..19aeddc 100644 --- a/lib/shared/design_system/theme/app_theme_sophisticated.dart +++ b/lib/shared/design_system/theme/app_theme_sophisticated.dart @@ -215,7 +215,7 @@ class AppThemeSophisticated { ); /// Configuration des cartes sophistiquées - static final CardTheme _cardTheme = CardTheme( + static final CardThemeData _cardTheme = CardThemeData( elevation: SpacingTokens.elevationSm, shadowColor: ColorTokens.shadow, surfaceTintColor: ColorTokens.surfaceContainer, @@ -368,7 +368,7 @@ class AppThemeSophisticated { ); /// Configuration des dialogues - static final DialogTheme _dialogTheme = DialogTheme( + static final DialogThemeData _dialogTheme = DialogThemeData( backgroundColor: ColorTokens.surfaceContainer, elevation: SpacingTokens.elevationLg, shadowColor: ColorTokens.shadow, @@ -419,7 +419,7 @@ class AppThemeSophisticated { ); /// Configuration des onglets - static final TabBarTheme _tabBarTheme = TabBarTheme( + static final TabBarThemeData _tabBarTheme = TabBarThemeData( labelColor: ColorTokens.primary, unselectedLabelColor: ColorTokens.onSurfaceVariant, labelStyle: TypographyTokens.titleSmall, diff --git a/pubspec.lock b/pubspec.lock index 137b99e..f023472 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "72.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.2" + version: "91.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "8.4.1" archive: dependency: transitive description: @@ -90,18 +85,18 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.5" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -110,30 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" url: "https://pub.dev" source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" + version: "2.13.1" built_collection: dependency: transitive description: @@ -178,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -202,10 +181,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -218,10 +197,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" connectivity_plus: dependency: "direct main" description: @@ -290,10 +269,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "3.1.3" dartz: dependency: "direct main" description: @@ -354,10 +333,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -632,10 +611,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "8.0.2" graphs: dependency: transitive description: @@ -752,10 +731,10 @@ packages: dependency: "direct dev" description: name: injectable_generator - sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1 + sha256: beac1179932b589ae1bc530c7f7dc4dedec4674d84aa452efa24f30b34495e00 url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.9.0" integration_test: dependency: "direct dev" description: flutter @@ -765,10 +744,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" io: dependency: transitive description: @@ -797,10 +776,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c + sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 url: "https://pub.dev" source: hosted - version: "6.9.0" + version: "6.11.2" jwt_decoder: dependency: "direct main" description: @@ -813,26 +792,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -889,38 +868,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" - url: "https://pub.dev" - source: hosted - version: "0.1.2-main.4" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.17.0" mime: dependency: transitive description: @@ -933,10 +904,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "5.6.4" mocktail: dependency: transitive description: @@ -1005,10 +976,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: @@ -1341,23 +1312,23 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "4.2.2" source_helper: dependency: transitive description: name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.8" source_map_stack_trace: dependency: transitive description: @@ -1426,18 +1397,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -1482,26 +1453,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.16" timezone: dependency: transitive description: @@ -1510,14 +1481,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.4" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1626,10 +1589,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1751,5 +1714,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.5.3 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2623443..a41a959 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: equatable: ^2.0.5 dio: ^5.7.0 fl_chart: ^0.66.2 - intl: ^0.19.0 + intl: 0.20.2 # Authentication (versions compatibles) flutter_secure_storage: ^9.2.2 @@ -48,7 +48,7 @@ dependencies: flutter_svg: ^2.0.10+1 shimmer: ^3.0.0 pull_to_refresh: ^2.0.0 - google_fonts: ^6.2.1 + google_fonts: ^8.0.0 local_auth: ^2.3.0 # Utils diff --git a/test/features/members/bloc/membres_bloc_test.dart b/test/features/members/bloc/membres_bloc_test.dart index ca6b502..4bb3b22 100644 --- a/test/features/members/bloc/membres_bloc_test.dart +++ b/test/features/members/bloc/membres_bloc_test.dart @@ -14,6 +14,7 @@ import 'package:unionflow_mobile_apps/features/members/domain/usecases/delete_me import 'package:unionflow_mobile_apps/features/members/domain/usecases/search_members.dart'; import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_member_stats.dart'; import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart'; +import 'package:unionflow_mobile_apps/features/members/data/models/membre_complete_model.dart'; import 'package:unionflow_mobile_apps/shared/models/membre_search_result.dart'; import 'package:unionflow_mobile_apps/shared/models/membre_search_criteria.dart'; @@ -292,6 +293,53 @@ void main() { // MembresLoaded.copyWith // ───────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── + // ResetMotDePasse + // ───────────────────────────────────────────────────────────────────────── + + group('ResetMotDePasse', () { + MembreCompletModel membreAvecPassword() => MembreCompletModel( + id: 'membre-id-1', + prenom: 'Jean', + nom: 'Dupont', + email: 'jean.dupont@test.com', + motDePasseTemporaire: 'NewP@ss1234', + ); + + blocTest( + 'émet [MembresLoading, MotDePasseReinitialise] en cas de succès', + build: () { + when(mockRepository.resetMotDePasse('membre-id-1')) + .thenAnswer((_) async => membreAvecPassword()); + return bloc; + }, + act: (b) => b.add(const ResetMotDePasse('membre-id-1')), + expect: () => [ + const MembresLoading(), + isA() + .having((s) => s.membre.id, 'id', 'membre-id-1') + .having((s) => s.membre.motDePasseTemporaire, 'password', 'NewP@ss1234'), + ], + verify: (_) { + verify(mockRepository.resetMotDePasse('membre-id-1')).called(1); + }, + ); + + blocTest( + 'émet [MembresLoading, MembresError] si le repository lève une exception', + build: () { + when(mockRepository.resetMotDePasse('membre-id-1')) + .thenThrow(Exception('Membre sans compte Keycloak')); + return bloc; + }, + act: (b) => b.add(const ResetMotDePasse('membre-id-1')), + expect: () => [ + const MembresLoading(), + isA(), + ], + ); + }); + group('MembresLoaded.copyWith', () { test('preserve organisationId si non fourni', () { const state = MembresLoaded(