diff --git a/lib/app/app.dart b/lib/app/app.dart index 286e320..b4090bb 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,6 @@ /// Configuration principale de l'application UnionFlow -/// -/// Contient la configuration globale de l'app avec thème, localisation et navigation +/// +/// Thème dynamique (light/dark) via ThemeProvider + localisation + navigation library app; import 'package:flutter/material.dart'; @@ -11,37 +11,49 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../shared/design_system/theme/app_theme_sophisticated.dart'; import '../features/authentication/presentation/bloc/auth_bloc.dart'; import '../core/l10n/locale_provider.dart'; +import '../core/theme/theme_provider.dart'; import '../core/di/injection.dart'; import 'router/app_router.dart'; -/// Application principale avec système d'authentification Keycloak +/// Application principale UnionFlow class UnionFlowApp extends StatelessWidget { final LocaleProvider localeProvider; + final ThemeProvider themeProvider; - const UnionFlowApp({super.key, required this.localeProvider}); + /// Clé globale pour afficher des SnackBars sans BuildContext (ex: session expirée) + static final GlobalKey scaffoldMessengerKey = + GlobalKey(); + + const UnionFlowApp({ + super.key, + required this.localeProvider, + required this.themeProvider, + }); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: localeProvider), + ChangeNotifierProvider.value(value: themeProvider), BlocProvider( create: (context) => getIt()..add(const AuthStatusChecked()), ), ], - child: Consumer( - builder: (context, localeProvider, child) { + child: Consumer2( + builder: (context, locale, theme, child) { return MaterialApp( title: 'UnionFlow', debugShowCheckedModeBanner: false, + scaffoldMessengerKey: UnionFlowApp.scaffoldMessengerKey, - // Configuration du thème + // Thème dynamique piloté par ThemeProvider theme: AppThemeSophisticated.lightTheme, darkTheme: AppThemeSophisticated.darkTheme, - themeMode: ThemeMode.system, + themeMode: theme.mode, - // Configuration de la localisation - locale: localeProvider.locale, + // Localisation + locale: locale.locale, supportedLocales: LocaleProvider.supportedLocales, localizationsDelegates: const [ AppLocalizations.delegate, @@ -50,13 +62,11 @@ class UnionFlowApp extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], - // Configuration des routes - routes: AppRouter.routes, + // Routes + routes: AppRouter.routes, + initialRoute: AppRouter.initialRoute, - // Page d'accueil par défaut - initialRoute: AppRouter.initialRoute, - - // Builder global pour gérer les erreurs + // Fix textScaler global builder: (context, child) { return MediaQuery( data: MediaQuery.of(context).copyWith( diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index b983fca..351c93a 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -24,21 +24,51 @@ import '../../features/communication/presentation/pages/conversations_page.dart' import '../../features/finance_workflow/presentation/pages/pending_approvals_page.dart'; import '../../features/finance_workflow/presentation/pages/budgets_list_page.dart'; import '../../core/navigation/main_navigation_layout.dart'; +import '../../features/onboarding/presentation/pages/onboarding_flow_page.dart'; /// Configuration des routes de l'application class AppRouter { /// Routes principales de l'application static Map get routes => { - '/': (context) => BlocBuilder( + '/': (context) => BlocConsumer( + listener: (context, state) { + // Compte bloqué (SUSPENDU / DESACTIVE) → dialog informatif + if (state is AuthAccountNotActive) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + icon: const Icon( + Icons.lock_person_outlined, + color: Color(0xFFB71C1C), + size: 48, + ), + title: const Text('Accès refusé'), + content: Text(state.message), + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(_).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + }, builder: (context, state) { if (state is AuthLoading) { return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), + body: Center(child: CircularProgressIndicator()), ); } else if (state is AuthAuthenticated) { return const MainNavigationLayout(); + } else if (state is AuthPendingOnboarding) { + // OrgAdmin EN_ATTENTE_VALIDATION → workflow d'onboarding + return OnboardingFlowPage( + onboardingState: state.onboardingState, + organisationId: state.organisationId ?? '', + souscriptionId: state.souscriptionId, + ); } else { return const LoginPage(); } diff --git a/lib/core/navigation/adaptive_navigation.dart b/lib/core/navigation/adaptive_navigation.dart index ce01728..1e5ad93 100644 --- a/lib/core/navigation/adaptive_navigation.dart +++ b/lib/core/navigation/adaptive_navigation.dart @@ -3,6 +3,8 @@ 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'; @@ -125,7 +127,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Super Administrateur', - const Color(0xFF6C5CE7), + AppColors.brandGreen, Icons.admin_panel_settings, items, ); @@ -181,7 +183,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Administrateur', - const Color(0xFF0984E3), + AppColors.primaryGreen, Icons.business_center, items, ); @@ -225,7 +227,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Modérateur', - const Color(0xFFE17055), + ColorTokens.secondaryDark, Icons.manage_accounts, items, ); @@ -275,7 +277,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Membre Actif', - const Color(0xFF00B894), + AppColors.brandGreenLight, Icons.groups, items, ); @@ -319,7 +321,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Membre', - const Color(0xFF00CEC9), + ColorTokens.secondary, Icons.person, items, ); @@ -363,7 +365,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'Visiteur', - const Color(0xFF6C5CE7), + AppColors.brandMint, Icons.waving_hand, items, ); @@ -374,7 +376,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { return _buildDrawer( context, 'UnionFlow', - Colors.grey, + AppColors.textSecondaryLight, Icons.dashboard, [ const AdaptiveNavigationItem( @@ -394,7 +396,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)], + colors: [AppColors.brandGreen, AppColors.primaryGreen], ), ), child: const Center( @@ -523,7 +525,7 @@ class AdaptiveNavigationDrawer extends StatelessWidget { ? Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Colors.red, + color: AppColors.error, borderRadius: BorderRadius.circular(12), ), child: Text( @@ -547,10 +549,10 @@ class AdaptiveNavigationDrawer extends StatelessWidget { /// Construit l'élément de déconnexion Widget _buildLogoutItem(BuildContext context) { return ListTile( - leading: const Icon(Icons.logout, color: Colors.red), + leading: Icon(Icons.logout, color: AppColors.error), title: const Text( 'Déconnexion', - style: TextStyle(color: Colors.red), + style: TextStyle(color: AppColors.error), ), onTap: () { Navigator.of(context).pop(); diff --git a/lib/core/navigation/main_navigation_layout.dart b/lib/core/navigation/main_navigation_layout.dart index 198d15b..617cad2 100644 --- a/lib/core/navigation/main_navigation_layout.dart +++ b/lib/core/navigation/main_navigation_layout.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'more_page.dart'; @@ -13,8 +14,8 @@ import '../../features/events/presentation/pages/events_page_wrapper.dart'; import '../../features/dashboard/presentation/bloc/dashboard_bloc.dart'; import '../di/injection.dart'; -/// Layout principal avec navigation hybride -/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées +/// Layout principal — Navigation hybride +/// Pill navigation (style Material 3) au bas de l'écran class MainNavigationLayout extends StatefulWidget { const MainNavigationLayout({super.key}); @@ -27,18 +28,20 @@ class _MainNavigationLayoutState extends State { List? _cachedPages; UserRole? _lastRole; String? _lastUserId; + String? _lastOrgId; - /// Obtient le dashboard approprié selon le rôle de l'utilisateur Widget _getDashboardForRole(UserRole role, String userId, String? orgId) { - // Admin d'organisation sans orgId (organizationContexts vide) : charger /mes puis dashboard if (role == UserRole.orgAdmin && (orgId == null || orgId.isEmpty)) { return OrgAdminDashboardLoader(userId: userId); } + // Pour SUPER_ADMIN sans org: forcer les stats globales (toutes organisations) + final isGlobalAdmin = role == UserRole.superAdmin && (orgId == null || orgId.isEmpty); return BlocProvider( create: (context) => getIt() ..add(LoadDashboardData( organizationId: orgId ?? '', userId: userId, + useGlobalDashboard: isGlobalAdmin, )), child: _buildDashboardView(role), ); @@ -65,21 +68,21 @@ class _MainNavigationLayoutState extends State { } } - /// Obtient les pages et les met en cache pour éviter les rebuilds inutiles List _getPages(UserRole role, String userId, String? orgId) { - if (_cachedPages != null && _lastRole == role && _lastUserId == userId) { + if (_cachedPages != null && _lastRole == role && _lastUserId == userId && _lastOrgId == orgId) { return _cachedPages!; } - - debugPrint('🔄 [MainNavigationLayout] Initialisation des pages (Role: $role, User: $userId)'); + debugPrint('🔄 [MainNavigationLayout] Init pages (Role: $role, User: $userId)'); _lastRole = role; _lastUserId = userId; - - final canManageMembers = role.hasLevelOrAbove(UserRole.hrManager); + _lastOrgId = orgId; _cachedPages = [ _getDashboardForRole(role, userId, orgId), - if (canManageMembers) const MembersPageWrapper(), + if (role.hasLevelOrAbove(UserRole.hrManager)) + MembersPageWrapper( + organisationId: role == UserRole.orgAdmin ? orgId : null, + ), const EventsPageWrapper(), const MorePage(), ]; @@ -96,74 +99,36 @@ class _MainNavigationLayoutState extends State { ); } - final orgId = state.user.organizationContexts.isNotEmpty - ? state.user.organizationContexts.first.organizationId + final orgId = state.user.organizationContexts.isNotEmpty + ? state.user.organizationContexts.first.organizationId : null; final pages = _getPages(state.effectiveRole, state.user.id, orgId); final safeIndex = _selectedIndex >= pages.length ? 0 : _selectedIndex; - return Scaffold( - backgroundColor: ColorTokens.background, - body: SafeArea( - top: true, // Respecte le StatusBar - bottom: false, // Le BottomNavigationBar gère son propre SafeArea - child: IndexedStack( - index: safeIndex, - children: pages, - ), + // Construire la liste des items de navigation selon le rôle + final navItems = _buildNavItems(state.effectiveRole); + + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, ), - bottomNavigationBar: SafeArea( - top: false, - child: Container( - decoration: const BoxDecoration( - color: ColorTokens.surface, - boxShadow: [ - BoxShadow( - color: ColorTokens.shadow, - blurRadius: 8, - offset: Offset(0, -2), - ), - ], + child: Scaffold( + backgroundColor: ColorTokens.background, + body: SafeArea( + top: true, + bottom: false, + child: IndexedStack( + index: safeIndex, + children: pages, ), - child: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - currentIndex: safeIndex, - onTap: (index) { - setState(() { - _selectedIndex = index; - }); - }, - backgroundColor: ColorTokens.surface, - selectedItemColor: ColorTokens.primary, - unselectedItemColor: ColorTokens.onSurfaceVariant, - selectedLabelStyle: TypographyTokens.labelSmall.copyWith( - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: TypographyTokens.labelSmall, - elevation: 0, // Géré par le Container - items: [ - const BottomNavigationBarItem( - icon: Icon(Icons.dashboard_outlined), - activeIcon: Icon(Icons.dashboard), - label: 'Dashboard', - ), - if (state.effectiveRole.hasLevelOrAbove(UserRole.hrManager)) - const BottomNavigationBarItem( - icon: Icon(Icons.people_outline), - activeIcon: Icon(Icons.people), - label: 'Membres', - ), - const BottomNavigationBarItem( - icon: Icon(Icons.event_outlined), - activeIcon: Icon(Icons.event), - label: 'Événements', - ), - const BottomNavigationBarItem( - icon: Icon(Icons.more_horiz_outlined), - activeIcon: Icon(Icons.more_horiz), - label: 'Plus', - ), - ], + ), + bottomNavigationBar: SafeArea( + top: false, + child: _PillNavigationBar( + items: navItems, + selectedIndex: safeIndex, + onItemTap: (i) => setState(() => _selectedIndex = i), ), ), ), @@ -171,4 +136,146 @@ class _MainNavigationLayoutState extends State { }, ); } + + List<_NavItem> _buildNavItems(UserRole role) { + return [ + const _NavItem( + icon: Icons.dashboard_outlined, + activeIcon: Icons.dashboard_rounded, + label: 'Dashboard', + ), + if (role.hasLevelOrAbove(UserRole.hrManager)) + const _NavItem( + icon: Icons.people_outline_rounded, + 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', + ), + ]; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pill Navigation Bar — Material 3 style +// ───────────────────────────────────────────────────────────────────────────── + +class _NavItem { + const _NavItem({ + required this.icon, + required this.activeIcon, + required this.label, + }); + + final IconData icon; + final IconData activeIcon; + final String label; +} + +class _PillNavigationBar extends StatelessWidget { + const _PillNavigationBar({ + required this.items, + required this.selectedIndex, + required this.onItemTap, + }); + + final List<_NavItem> items; + final int selectedIndex; + final ValueChanged onItemTap; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: ColorTokens.surface, + boxShadow: [ + BoxShadow( + color: ColorTokens.shadow, + blurRadius: 12, + offset: const Offset(0, -2), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(items.length, (i) { + return _PillNavItem( + item: items[i], + isSelected: i == selectedIndex, + onTap: () => onItemTap(i), + ); + }), + ), + ); + } +} + +class _PillNavItem extends StatelessWidget { + const _PillNavItem({ + required this.item, + required this.isSelected, + required this.onTap, + }); + + final _NavItem item; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + padding: EdgeInsets.symmetric( + horizontal: isSelected ? 16 : 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? ColorTokens.primary.withOpacity(0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(24), + ), + child: isSelected + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + item.activeIcon, + size: 22, + color: ColorTokens.primary, + ), + const SizedBox(width: 6), + Text( + item.label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + color: ColorTokens.primary, + letterSpacing: 0.2, + ), + ), + ], + ) + : Icon( + item.icon, + size: 22, + color: ColorTokens.navigationUnselected, + ), + ), + ); + } } diff --git a/lib/core/navigation/more_page.dart b/lib/core/navigation/more_page.dart index be84565..4c312ca 100644 --- a/lib/core/navigation/more_page.dart +++ b/lib/core/navigation/more_page.dart @@ -18,7 +18,7 @@ import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper. import '../../features/organizations/presentation/pages/organizations_page_wrapper.dart'; import '../../features/profile/presentation/pages/profile_page_wrapper.dart'; -/// Page "Plus" avec les fonctions avancées selon le rôle (Menu Principal Extensif) +/// Page "Plus" avec les fonctions avancées selon le rôle class MorePage extends StatelessWidget { const MorePage({super.key}); @@ -33,26 +33,20 @@ class MorePage extends StatelessWidget { } return Scaffold( - backgroundColor: ColorTokens.background, + backgroundColor: AppColors.lightBackground, appBar: const UFAppBar( title: 'PLUS', automaticallyImplyLeading: false, ), body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Profil utilisateur _buildUserProfile(context, state), const SizedBox(height: SpacingTokens.md), - - // Options selon le rôle ..._buildRoleBasedOptions(context, state), - const SizedBox(height: SpacingTokens.md), - - // Options communes ..._buildCommonOptions(context), ], ), @@ -63,14 +57,20 @@ class MorePage extends StatelessWidget { } Widget _buildUserProfile(BuildContext context, AuthAuthenticated state) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final roleColor = isDark ? AppColors.brandGreenLight : AppColors.primaryGreen; + return CoreCard( onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ProfilePageWrapper()), + MaterialPageRoute(builder: (_) => const ProfilePageWrapper()), ), child: Row( children: [ MiniAvatar( - fallbackText: state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U', + fallbackText: state.user.firstName.isNotEmpty + ? state.user.firstName[0].toUpperCase() + : 'U', size: 40, imageUrl: state.user.avatar, ), @@ -81,19 +81,21 @@ class MorePage extends StatelessWidget { children: [ Text( '${state.user.firstName} ${state.user.lastName}', - style: AppTypography.actionText, + style: AppTypography.actionText.copyWith(color: textColor), ), Text( state.effectiveRole.displayName.toUpperCase(), style: AppTypography.badgeText.copyWith( - color: AppColors.primaryGreen, + color: roleColor, fontWeight: FontWeight.bold, ), ), ], ), ), - const Icon(Icons.chevron_right, color: AppColors.textSecondaryLight, size: 16), + Icon(Icons.chevron_right, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + size: 16), ], ), ); @@ -102,118 +104,90 @@ class MorePage extends StatelessWidget { List _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) { final options = []; - // Options Super Admin uniquement if (state.effectiveRole == UserRole.superAdmin) { options.addAll([ - _buildSectionTitle('Administration Système'), - _buildOptionTile( + _buildSectionTitle(context, 'Administration Système'), + _buildOptionTile(context, icon: Icons.people, title: 'Gestion des utilisateurs', subtitle: 'Utilisateurs Keycloak et rôles', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const UserManagementPage()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UserManagementPage())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.settings, title: 'Paramètres Système', subtitle: 'Configuration globale', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const SystemSettingsPage()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SystemSettingsPage())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.backup, title: 'Sauvegarde & Restauration', subtitle: 'Gestion des sauvegardes', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const BackupPage()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BackupPage())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.article, title: 'Logs & Monitoring', subtitle: 'Surveillance et journaux', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const LogsPage()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LogsPage())), ), ]); } - // Options Admin+ (Admin Organisation et Super Admin) - if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) { + if (state.effectiveRole == UserRole.orgAdmin || + state.effectiveRole == UserRole.superAdmin) { options.addAll([ - _buildSectionTitle('Administration'), - _buildOptionTile( + _buildSectionTitle(context, 'Administration'), + _buildOptionTile(context, icon: Icons.business, title: 'Gestion des Organisations', subtitle: 'Créer et gérer les organisations', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const OrganizationsPageWrapper()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const OrganizationsPageWrapper())), ), - _buildSectionTitle('Workflow Financier'), - _buildOptionTile( + _buildSectionTitle(context, 'Workflow Financier'), + _buildOptionTile(context, icon: Icons.pending_actions, title: 'Approbations en attente', subtitle: 'Valider les transactions financières', - onTap: () { - Navigator.pushNamed(context, '/approvals'); - }, + onTap: () => Navigator.pushNamed(context, '/approvals'), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.account_balance_wallet, title: 'Gestion des Budgets', subtitle: 'Créer et suivre les budgets', - onTap: () { - Navigator.pushNamed(context, '/budgets'); - }, + onTap: () => Navigator.pushNamed(context, '/budgets'), ), - _buildSectionTitle('Communication'), - _buildOptionTile( + _buildSectionTitle(context, 'Communication'), + _buildOptionTile(context, icon: Icons.message, title: 'Messages & Broadcast', subtitle: 'Communiquer avec les membres', - onTap: () { - Navigator.pushNamed(context, '/messages'); - }, + onTap: () => Navigator.pushNamed(context, '/messages'), ), - _buildSectionTitle('Rapports & Analytics'), - _buildOptionTile( + _buildSectionTitle(context, 'Rapports & Analytics'), + _buildOptionTile(context, icon: Icons.assessment, title: 'Rapports & Analytics', subtitle: 'Statistiques détaillées', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ReportsPageWrapper()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), ), ]); } - // Options Modérateur (Communication limitée) if (state.effectiveRole == UserRole.moderator) { options.addAll([ - _buildSectionTitle('Communication'), - _buildOptionTile( + _buildSectionTitle(context, 'Communication'), + _buildOptionTile(context, icon: Icons.message, title: 'Messages aux membres', subtitle: 'Communiquer avec les membres', - onTap: () { - Navigator.pushNamed(context, '/messages'); - }, + onTap: () => Navigator.pushNamed(context, '/messages'), ), ]); } @@ -223,129 +197,83 @@ class MorePage extends StatelessWidget { List _buildCommonOptions(BuildContext context) { return [ - _buildSectionTitle('Général'), - _buildOptionTile( - icon: Icons.person_outline, - title: 'Mon profil', - subtitle: 'Voir et modifier mon profil', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ProfilePageWrapper()), - ); - }, - ), - _buildOptionTile( + _buildSectionTitle(context, 'Général'), + _buildOptionTile(context, icon: Icons.payment, title: 'Cotisations', subtitle: 'Gérer les cotisations', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const CotisationsPageWrapper()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CotisationsPageWrapper())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.how_to_reg, title: 'Demandes d\'adhésion', subtitle: 'Demandes d\'adhésion à une organisation', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const AdhesionsPageWrapper()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.volunteer_activism, title: 'Demandes d\'aide', subtitle: 'Solidarité – demandes d\'aide', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const DemandesAidePageWrapper()), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DemandesAidePageWrapper())), ), - _buildOptionTile( + _buildOptionTile(context, icon: Icons.savings_outlined, title: 'Comptes épargne', subtitle: 'Mutuelle épargne – dépôts (LCB-FT)', - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const EpargnePage()), - ); - }, - ), - const SizedBox(height: 8), - _buildOptionTile( - icon: Icons.logout, - title: 'Déconnexion', - subtitle: 'Se déconnecter de l\'application', - color: AppColors.error, - onTap: () { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Déconnexion'), - content: const Text('Voulez-vous vraiment vous déconnecter ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Annuler'), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - context.read().add(AuthLogoutRequested()); - }, - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Déconnecter'), - ), - ], - ), - ); - }, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const EpargnePage())), ), ]; } - Widget _buildSectionTitle(String title) { + Widget _buildSectionTitle(BuildContext context, String title) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 8, left: 4), + padding: const EdgeInsets.only(top: 16, bottom: 6, left: 4), child: Text( title.toUpperCase(), style: AppTypography.subtitleSmall.copyWith( fontWeight: FontWeight.bold, letterSpacing: 1.1, - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ); } - Widget _buildOptionTile({ + Widget _buildOptionTile( + BuildContext context, { required IconData icon, required String title, required String subtitle, required VoidCallback onTap, - Color? color, + Color? accentColor, }) { - final effectiveColor = color ?? AppColors.primaryGreen; - + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = accentColor ?? AppColors.primaryGreen; + final titleColor = accentColor != null + ? accentColor + : (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight); + final subtitleColor = + isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + final chevronColor = + isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + return CoreCard( margin: const EdgeInsets.only(bottom: 8), onTap: onTap, child: Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( - color: effectiveColor.withOpacity(0.1), + color: accent.withOpacity(isDark ? 0.2 : 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - icon, - color: effectiveColor, - size: 20, - ), + child: Icon(icon, color: accent, size: 18), ), const SizedBox(width: 16), Expanded( @@ -354,22 +282,16 @@ class MorePage extends StatelessWidget { children: [ Text( title, - style: AppTypography.actionText.copyWith( - color: color ?? AppColors.textPrimaryLight, - ), + style: AppTypography.actionText.copyWith(color: titleColor), ), Text( subtitle, - style: AppTypography.subtitleSmall, + style: AppTypography.subtitleSmall.copyWith(color: subtitleColor), ), ], ), ), - const Icon( - Icons.chevron_right, - color: AppColors.textSecondaryLight, - size: 16, - ), + Icon(Icons.chevron_right, color: chevronColor, size: 16), ], ), ); diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 1e98ecc..76ed685 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -1,7 +1,9 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:injectable/injectable.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../app/app.dart'; import '../config/environment.dart'; import '../di/injection.dart'; import '../error/error_handler.dart'; @@ -80,6 +82,10 @@ class ApiClient { if (retryError.response?.statusCode == 401) { debugPrint('🚪 [API] Persistent 401. Force Logout.'); _forceLogout(); + return handler.reject(DioException( + requestOptions: retryError.requestOptions, + type: DioExceptionType.cancel, + )); } return handler.next(retryError); } catch (retryError) { @@ -90,6 +96,10 @@ class ApiClient { } else { debugPrint('🚪 [API] Refresh failed. Force Logout.'); _forceLogout(); + return handler.reject(DioException( + requestOptions: e.requestOptions, + type: DioExceptionType.cancel, + )); } } return handler.next(e); @@ -100,6 +110,27 @@ class ApiClient { void _forceLogout() { try { + UnionFlowApp.scaffoldMessengerKey.currentState + ?..clearSnackBars() + ..showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.lock_clock, color: Colors.white, size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Session expirée. Vous avez été déconnecté automatiquement.', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + backgroundColor: Colors.orange.shade700, + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); final authBloc = getIt(); authBloc.add(const AuthLogoutRequested()); } catch (e, st) { diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart new file mode 100644 index 0000000..c160dfa --- /dev/null +++ b/lib/core/theme/theme_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Provider de thème — light / dark avec persistance +class ThemeProvider extends ChangeNotifier { + static const String _key = 'uf_theme_mode'; + + ThemeMode _mode; + + ThemeProvider({ThemeMode initial = ThemeMode.system}) : _mode = initial; + + ThemeMode get mode => _mode; + + bool get isDark => _mode == ThemeMode.dark; + + Future toggle() async { + _mode = isDark ? ThemeMode.light : ThemeMode.dark; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, _mode.name); + } + + Future setMode(ThemeMode mode) async { + if (_mode == mode) return; + _mode = mode; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, mode.name); + } + + /// Charge le thème persisté depuis les préférences + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_key); + final mode = switch (stored) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + return ThemeProvider(initial: mode); + } +} diff --git a/lib/core/websocket/websocket_service.dart b/lib/core/websocket/websocket_service.dart index 432c19a..3988348 100644 --- a/lib/core/websocket/websocket_service.dart +++ b/lib/core/websocket/websocket_service.dart @@ -284,14 +284,18 @@ class WebSocketService { void _onError(dynamic error) { AppLogger.error('WebSocket error', error: error); _isConnected = false; + _stopHeartbeat(); + _channel = null; _connectionStatusController.add(false); _scheduleReconnect(); } /// Gestion de la fermeture de connexion void _onDone() { + if (!_isConnected) return; // Déjà traité par _onError AppLogger.info('WebSocket connexion fermée'); _isConnected = false; + _channel = null; _connectionStatusController.add(false); _stopHeartbeat(); _scheduleReconnect(); @@ -303,6 +307,10 @@ class WebSocketService { return; } + if (_reconnectTimer != null) { + return; // Reconnexion déjà planifiée + } + _stopReconnectTimer(); // Backoff exponentiel : 2^attempts secondes (max 60s) @@ -312,6 +320,7 @@ class WebSocketService { AppLogger.info('⏳ Reconnexion WebSocket dans ${delaySeconds}s (tentative $_reconnectAttempts)'); _reconnectTimer = Timer(Duration(seconds: delaySeconds), () { + _channel = null; AppLogger.info('🔄 Tentative de reconnexion WebSocket...'); connect(); }); diff --git a/lib/features/about/presentation/pages/about_page.dart b/lib/features/about/presentation/pages/about_page.dart index 0bf562b..e9c818d 100644 --- a/lib/features/about/presentation/pages/about_page.dart +++ b/lib/features/about/presentation/pages/about_page.dart @@ -49,30 +49,30 @@ class _AboutPageState extends State { ], ), body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header harmonisé _buildHeader(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Informations de l'application _buildAppInfoSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Équipe de développement _buildTeamSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Fonctionnalités _buildFeaturesSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Liens utiles _buildLinksSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Support et contact _buildSupportSection(), const SizedBox(height: 80), @@ -88,18 +88,18 @@ class _AboutPageState extends State { child: Column( children: [ Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.primaryGreen.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.account_balance, color: AppColors.primaryGreen, - size: 48, + size: 32, ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( 'UNIONFLOW MOBILE', style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.2), @@ -130,7 +130,7 @@ class _AboutPageState extends State { 'INFORMATIONS', style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildInfoRow('Construction', _packageInfo?.buildNumber ?? '...'), _buildInfoRow('Package', _packageInfo?.packageName ?? '...'), _buildInfoRow('Plateforme', 'Android / iOS'), @@ -173,7 +173,7 @@ class _AboutPageState extends State { 'ÉQUIPE', style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildTeamMember( 'UnionFlow Team', 'Architecture & Dev', @@ -184,7 +184,7 @@ class _AboutPageState extends State { 'Design System', 'UI / UX Focus', Icons.design_services, - AppColors.info, + AppColors.brandGreenLight, ), ], ), @@ -230,9 +230,9 @@ class _AboutPageState extends State { 'FONCTIONNALITÉS', style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildFeatureItem('Membres', 'Administration complète', Icons.people, AppColors.primaryGreen), - _buildFeatureItem('Organisations', 'Syndicats & Fédérations', Icons.business, AppColors.info), + _buildFeatureItem('Organisations', 'Syndicats & Fédérations', Icons.business, AppColors.brandGreenLight), _buildFeatureItem('Événements', 'Planification & Suivi', Icons.event, AppColors.success), _buildFeatureItem('Sécurité', 'Auth Keycloak OIDC', Icons.security, AppColors.warning), ], @@ -272,7 +272,7 @@ class _AboutPageState extends State { 'LIENS UTILES', style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildLinkItem('Site Web', 'https://unionflow.com', Icons.web, () => _launchUrl('https://unionflow.com')), _buildLinkItem('Documentation', 'Guide d\'utilisation', Icons.book, () => _launchUrl('https://docs.unionflow.com')), _buildLinkItem('Confidentialité', 'Protection des données', Icons.privacy_tip, () => _launchUrl('https://unionflow.com/privacy')), @@ -319,10 +319,10 @@ class _AboutPageState extends State { 'SUPPORT', style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildSupportItem('Email', 'support@unionflow.com', Icons.email, () => _launchUrl('mailto:support@unionflow.com')), _buildSupportItem('Bug', 'Signaler un problème', Icons.bug_report, () => _showBugReportDialog()), - const SizedBox(height: 24), + const SizedBox(height: 12), const Center( child: Column( children: [ @@ -398,8 +398,8 @@ class _AboutPageState extends State { _launchUrl('mailto:support@unionflow.com?subject=Rapport de bug - UnionFlow Mobile'); }, style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, ), child: const Text('Envoyer un email'), ), @@ -429,8 +429,8 @@ class _AboutPageState extends State { _launchUrl('mailto:support@unionflow.com?subject=Suggestion d\'amélioration - UnionFlow Mobile'); }, style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, ), child: const Text('Envoyer une suggestion'), ), @@ -460,8 +460,8 @@ class _AboutPageState extends State { _launchStoreForRating(); }, style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, ), child: const Text('Évaluer maintenant'), ), @@ -514,7 +514,7 @@ class _AboutPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFFE74C3C), + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); diff --git a/lib/features/adhesions/bloc/adhesions_bloc.dart b/lib/features/adhesions/bloc/adhesions_bloc.dart index 0a9a7a2..f3762c8 100644 --- a/lib/features/adhesions/bloc/adhesions_bloc.dart +++ b/lib/features/adhesions/bloc/adhesions_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des adhésions (demandes d'adhésion) library adhesions_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; @@ -34,6 +35,7 @@ class AdhesionsBloc extends Bloc { final list = await _repository.getAll(page: event.page, size: event.size); emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -44,6 +46,7 @@ class AdhesionsBloc extends Bloc { final list = await _repository.getByMembre(event.membreId, page: event.page, size: event.size); emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -55,6 +58,7 @@ class AdhesionsBloc extends Bloc { final list = await _repository.getEnAttente(page: event.page, size: event.size); emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -65,6 +69,7 @@ class AdhesionsBloc extends Bloc { final list = await _repository.getByStatut(event.statut, page: event.page, size: event.size); emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -75,6 +80,7 @@ class AdhesionsBloc extends Bloc { final adhesion = await _repository.getById(event.id); emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: adhesion)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -85,6 +91,7 @@ class AdhesionsBloc extends Bloc { await _repository.create(event.adhesion); add(const LoadAdhesions()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -96,6 +103,7 @@ class AdhesionsBloc extends Bloc { emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated)); add(const LoadAdhesions()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -107,6 +115,7 @@ class AdhesionsBloc extends Bloc { emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated)); add(const LoadAdhesions()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -123,6 +132,7 @@ class AdhesionsBloc extends Bloc { emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated)); add(const LoadAdhesions()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e)); } } @@ -132,6 +142,7 @@ class AdhesionsBloc extends Bloc { final stats = await _repository.getStats(); emit(state.copyWith(stats: stats)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('AdhesionsBloc: chargement stats échoué', error: e, stackTrace: st); emit(state.copyWith( status: AdhesionsStatus.error, diff --git a/lib/features/adhesions/presentation/pages/adhesion_detail_page.dart b/lib/features/adhesions/presentation/pages/adhesion_detail_page.dart index 5337c1c..21201ce 100644 --- a/lib/features/adhesions/presentation/pages/adhesion_detail_page.dart +++ b/lib/features/adhesions/presentation/pages/adhesion_detail_page.dart @@ -42,7 +42,7 @@ class _AdhesionDetailPageState extends State { listener: (context, state) { if (state.status == AdhesionsStatus.error && state.message != null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message!), backgroundColor: Colors.red), + SnackBar(content: Text(state.message!), backgroundColor: AppColors.error), ); } }, @@ -58,7 +58,7 @@ class _AdhesionDetailPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, size: 64, color: Colors.grey), + const Icon(Icons.error_outline, size: 64, color: AppColors.textSecondaryLight), const SizedBox(height: 16), Text( 'Adhésion introuvable', @@ -69,7 +69,7 @@ class _AdhesionDetailPageState extends State { ); } return SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -190,7 +190,7 @@ Widget _buildStatutBadge(String? statut) { color = AppColors.brandGreenLight; break; case 'EN_PAIEMENT': - color = Colors.blue; + color = AppColors.warning; break; default: color = AppColors.textSecondaryLight; diff --git a/lib/features/adhesions/presentation/pages/adhesions_page.dart b/lib/features/adhesions/presentation/pages/adhesions_page.dart index 17af78f..6cacd4b 100644 --- a/lib/features/adhesions/presentation/pages/adhesions_page.dart +++ b/lib/features/adhesions/presentation/pages/adhesions_page.dart @@ -76,7 +76,7 @@ class _AdhesionsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message!), - backgroundColor: Colors.red, + backgroundColor: AppColors.error, action: SnackBarAction( label: 'Réessayer', textColor: Colors.white, @@ -146,11 +146,11 @@ class _AdhesionsPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.assignment_outlined, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), + Icon(Icons.assignment_outlined, size: 40, color: AppColors.textSecondaryLight), + const SizedBox(height: 8), Text( 'Aucune demande d\'adhésion', - style: TextStyle(fontSize: 16, color: Colors.grey[600]), + style: AppTypography.bodyTextSmall.copyWith(fontSize: 16, color: AppColors.textSecondaryLight), ), const SizedBox(height: 8), TextButton.icon( @@ -304,7 +304,7 @@ class _AdhesionCard extends StatelessWidget { color = AppColors.brandGreenLight; break; case 'EN_PAIEMENT': - color = Colors.blue; + color = AppColors.warning; break; default: color = AppColors.textSecondaryLight; diff --git a/lib/features/adhesions/presentation/widgets/create_adhesion_dialog.dart b/lib/features/adhesions/presentation/widgets/create_adhesion_dialog.dart index ebc652a..ac56d28 100644 --- a/lib/features/adhesions/presentation/widgets/create_adhesion_dialog.dart +++ b/lib/features/adhesions/presentation/widgets/create_adhesion_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../bloc/adhesions_bloc.dart'; import '../../data/models/adhesion_model.dart'; import '../../../organizations/data/models/organization_model.dart'; @@ -124,7 +125,7 @@ class _CreateAdhesionDialogState extends State { enabled: false, ) else - const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)), + const Text('Impossible de récupérer votre profil', style: TextStyle(color: AppColors.error)), const SizedBox(height: 16), DropdownButtonFormField( value: _organisationId, diff --git a/lib/features/adhesions/presentation/widgets/rejet_adhesion_dialog.dart b/lib/features/adhesions/presentation/widgets/rejet_adhesion_dialog.dart index 60b57c5..8c8030d 100644 --- a/lib/features/adhesions/presentation/widgets/rejet_adhesion_dialog.dart +++ b/lib/features/adhesions/presentation/widgets/rejet_adhesion_dialog.dart @@ -3,6 +3,7 @@ library rejet_adhesion_dialog; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../bloc/adhesions_bloc.dart'; class RejetAdhesionDialog extends StatefulWidget { @@ -86,7 +87,7 @@ class _RejetAdhesionDialogState extends State { ), FilledButton( onPressed: _loading ? null : _submit, - style: FilledButton.styleFrom(backgroundColor: Colors.red), + style: FilledButton.styleFrom(backgroundColor: AppColors.error), child: _loading ? const SizedBox( width: 20, diff --git a/lib/features/admin/bloc/admin_users_bloc.dart b/lib/features/admin/bloc/admin_users_bloc.dart index 406633a..d951043 100644 --- a/lib/features/admin/bloc/admin_users_bloc.dart +++ b/lib/features/admin/bloc/admin_users_bloc.dart @@ -1,5 +1,6 @@ library admin_users_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../data/models/admin_user_model.dart'; @@ -35,6 +36,7 @@ class AdminUsersBloc extends Bloc { totalPages: result.totalPages, )); } catch (err) { + if (err is DioException && err.type == DioExceptionType.cancel) return; emit(AdminUsersError(err.toString())); } } @@ -50,6 +52,7 @@ class AdminUsersBloc extends Bloc { final roles = await _repository.getUserRoles(e.userId); emit(AdminUserDetailLoaded(user: user, userRoles: roles)); } catch (err) { + if (err is DioException && err.type == DioExceptionType.cancel) return; emit(AdminUsersError(err.toString())); } } @@ -66,6 +69,7 @@ class AdminUsersBloc extends Bloc { final allRoles = await _repository.getRealmRoles(); emit(AdminUserDetailLoaded(user: user, userRoles: userRoles, allRoles: allRoles)); } catch (err) { + if (err is DioException && err.type == DioExceptionType.cancel) return; emit(AdminUsersError(err.toString())); } } @@ -76,6 +80,7 @@ class AdminUsersBloc extends Bloc { emit(AdminUserRolesUpdated()); add(AdminUserDetailWithRolesRequested(e.userId)); } catch (err) { + if (err is DioException && err.type == DioExceptionType.cancel) return; emit(AdminUsersError(err.toString())); } } @@ -85,6 +90,7 @@ class AdminUsersBloc extends Bloc { final roles = await _repository.getRealmRoles(); emit(AdminRolesLoaded(roles)); } catch (err) { + if (err is DioException && err.type == DioExceptionType.cancel) return; emit(AdminUsersError(err.toString())); } } diff --git a/lib/features/admin/presentation/pages/user_management_detail_page.dart b/lib/features/admin/presentation/pages/user_management_detail_page.dart index dad0640..1fea58e 100644 --- a/lib/features/admin/presentation/pages/user_management_detail_page.dart +++ b/lib/features/admin/presentation/pages/user_management_detail_page.dart @@ -97,7 +97,7 @@ class _UserDetailContentState extends State<_UserDetailContent> { } }, child: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -121,7 +121,7 @@ class _UserDetailContentState extends State<_UserDetailContent> { ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( 'RÔLES (SÉLECTION)', style: AppTypography.subtitleSmall.copyWith( @@ -149,7 +149,7 @@ class _UserDetailContentState extends State<_UserDetailContent> { }, ); }), - const SizedBox(height: 24), + const SizedBox(height: 12), UFPrimaryButton( label: 'Enregistrer les rôles', onPressed: () { @@ -158,9 +158,9 @@ class _UserDetailContentState extends State<_UserDetailContent> { ); }, ), - const SizedBox(height: 24), + const SizedBox(height: 12), const Divider(height: 1), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( 'ASSOCIER À UNE ORGANISATION', style: AppTypography.subtitleSmall.copyWith( diff --git a/lib/features/admin/presentation/pages/user_management_page.dart b/lib/features/admin/presentation/pages/user_management_page.dart index 3fff903..116e06c 100644 --- a/lib/features/admin/presentation/pages/user_management_page.dart +++ b/lib/features/admin/presentation/pages/user_management_page.dart @@ -181,7 +181,7 @@ class _UserManagementViewState extends State<_UserManagementView> { Widget _buildPagination(BuildContext context, AdminUsersLoaded state) { if (state.totalPages <= 1) return const SizedBox(height: 24); return Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/features/authentication/data/datasources/keycloak_auth_service.dart b/lib/features/authentication/data/datasources/keycloak_auth_service.dart index 993930f..f205718 100644 --- a/lib/features/authentication/data/datasources/keycloak_auth_service.dart +++ b/lib/features/authentication/data/datasources/keycloak_auth_service.dart @@ -178,4 +178,56 @@ class KeycloakAuthService { if (token != null && !JwtDecoder.isExpired(token)) return token; return await refreshToken(); } + + /// Vérifie le statut du compte sur le backend UnionFlow. + /// + /// Retourne un [AuthStatusResult] enrichi avec l'état d'onboarding, + /// ou `null` en cas d'erreur réseau (on ne bloque pas en cas de doute). + Future getAuthStatus(String apiBaseUrl) async { + try { + final token = await getValidToken(); + if (token == null) return null; + final response = await _dio.get( + '$apiBaseUrl/api/membres/mon-statut', + options: Options( + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (s) => s != null && s < 500, + ), + ); + if (response.statusCode == 200 && response.data is Map) { + final data = response.data as Map; + return AuthStatusResult( + statutCompte: (data['statutCompte'] as String?) ?? 'ACTIF', + onboardingState: (data['onboardingState'] as String?) ?? 'NO_SUBSCRIPTION', + souscriptionId: data['souscriptionId'] as String?, + waveSessionId: data['waveSessionId'] as String?, + organisationId: data['organisationId'] as String?, + ); + } + } catch (e) { + AppLogger.warning('KeycloakAuthService: impossible de vérifier statut compte: $e'); + } + return null; + } +} + +/// Résultat enrichi de /api/membres/mon-statut +class AuthStatusResult { + final String statutCompte; + final String onboardingState; + final String? souscriptionId; + final String? waveSessionId; + final String? organisationId; + + const AuthStatusResult({ + required this.statutCompte, + required this.onboardingState, + this.souscriptionId, + this.waveSessionId, + this.organisationId, + }); + + bool get isActive => statutCompte == 'ACTIF'; + bool get isPendingOnboarding => statutCompte == 'EN_ATTENTE_VALIDATION'; + bool get isBlocked => statutCompte == 'SUSPENDU' || statutCompte == 'DESACTIVE'; } diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index 4c24afd..7d17706 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -5,7 +5,11 @@ import '../../data/models/user.dart'; 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/storage/dashboard_cache_manager.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../core/di/injection.dart'; +import '../../../organizations/domain/repositories/organization_repository.dart'; // === ÉVÉNEMENTS === abstract class AuthEvent extends Equatable { @@ -61,6 +65,30 @@ class AuthError extends AuthState { List get props => [message]; } +/// Compte bloqué (SUSPENDU ou DESACTIVE) — déconnexion + message. +class AuthAccountNotActive extends AuthState { + final String statutCompte; + final String message; + const AuthAccountNotActive({required this.statutCompte, required this.message}); + @override + List get props => [statutCompte, message]; +} + +/// Compte EN_ATTENTE_VALIDATION — l'OrgAdmin doit compléter l'onboarding. +/// On ne déconnecte PAS pour permettre les appels API de souscription. +class AuthPendingOnboarding extends AuthState { + final String onboardingState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION + final String? souscriptionId; + final String? organisationId; + const AuthPendingOnboarding({ + required this.onboardingState, + this.souscriptionId, + this.organisationId, + }); + @override + List get props => [onboardingState, souscriptionId, organisationId]; +} + // === BLOC === @lazySingleton class AuthBloc extends Bloc { @@ -76,12 +104,40 @@ class AuthBloc extends Bloc { Future _onLoginRequested(AuthLoginRequested event, Emitter emit) async { emit(AuthLoading()); try { - final user = await _authService.login(event.email, event.password); - if (user != null) { + final rawUser = await _authService.login(event.email, event.password); + if (rawUser != null) { + // Vérification du statut du compte UnionFlow (indépendant de Keycloak) + final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); + + if (status != null && status.isPendingOnboarding) { + // OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter) + final user = await _enrichUserWithOrgContext(rawUser); + final orgId = status.organisationId ?? + (user.organizationContexts.isNotEmpty + ? user.organizationContexts.first.organizationId + : null); + emit(AuthPendingOnboarding( + onboardingState: status.onboardingState, + souscriptionId: status.souscriptionId, + organisationId: orgId, + )); + return; + } + + if (status != null && status.isBlocked) { + await _authService.logout(); + emit(AuthAccountNotActive( + statutCompte: status.statutCompte, + message: _messageForStatut(status.statutCompte), + )); + return; + } + + final user = await _enrichUserWithOrgContext(rawUser); final permissions = await PermissionEngine.getEffectivePermissions(user); final token = await _authService.getValidToken(); await DashboardCacheManager.invalidateForRole(user.primaryRole); - + emit(AuthAuthenticated( user: user, effectiveRole: user.primaryRole, @@ -110,11 +166,39 @@ class AuthBloc extends Bloc { emit(AuthUnauthenticated()); return; } - final user = await _authService.getCurrentUser(); - if (user == null) { + final rawUser = await _authService.getCurrentUser(); + if (rawUser == null) { emit(AuthUnauthenticated()); return; } + + // Vérification du statut du compte (au redémarrage de l'app) + final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); + + if (status != null && status.isPendingOnboarding) { + final user = await _enrichUserWithOrgContext(rawUser); + final orgId = status.organisationId ?? + (user.organizationContexts.isNotEmpty + ? user.organizationContexts.first.organizationId + : null); + emit(AuthPendingOnboarding( + onboardingState: status.onboardingState, + souscriptionId: status.souscriptionId, + organisationId: orgId, + )); + return; + } + + if (status != null && status.isBlocked) { + await _authService.logout(); + emit(AuthAccountNotActive( + statutCompte: status.statutCompte, + message: _messageForStatut(status.statutCompte), + )); + return; + } + + final user = await _enrichUserWithOrgContext(rawUser); final permissions = await PermissionEngine.getEffectivePermissions(user); final token = await _authService.getValidToken(); emit(AuthAuthenticated( @@ -125,6 +209,51 @@ class AuthBloc extends Bloc { )); } + /// Retourne un message lisible selon le statut du compte. + String _messageForStatut(String statut) { + switch (statut) { + case 'EN_ATTENTE_VALIDATION': + return 'Votre compte est en attente de validation par un administrateur. Vous serez notifié dès que votre accès sera activé.'; + case 'SUSPENDU': + return 'Votre compte a été suspendu temporairement. Contactez votre administrateur pour plus d\'informations.'; + case 'DESACTIVE': + return 'Votre compte a été désactivé. Contactez votre administrateur.'; + default: + return 'Votre compte n\'est pas encore actif. Contactez votre administrateur.'; + } + } + + /// Enrichit le contexte organisationnel pour les AdminOrganisation. + /// + /// 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. + Future _enrichUserWithOrgContext(User user) async { + if (user.primaryRole != UserRole.orgAdmin || + user.organizationContexts.isNotEmpty) { + return user; + } + try { + final orgRepo = getIt(); + final orgs = await orgRepo.getMesOrganisations(); + if (orgs.isEmpty) return user; + final contexts = orgs + .where((o) => o.id != null && o.id!.isNotEmpty) + .map( + (o) => UserOrganizationContext( + organizationId: o.id!, + organizationName: o.nom, + role: UserRole.orgAdmin, + joinedAt: DateTime.now(), + ), + ) + .toList(); + return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts); + } catch (e) { + AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e'); + return user; + } + } + Future _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter emit) async { if (state is AuthAuthenticated) { final newToken = await _authService.refreshToken(); diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 73cb4fd..4067d1f 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -1,68 +1,133 @@ +import 'dart:math' as math; + 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'; import '../../../../core/config/environment.dart'; -import '../../../../shared/widgets/core_text_field.dart'; -import '../../../../shared/widgets/dynamic_fab.dart'; -import '../../../../shared/design_system/tokens/app_typography.dart'; -import '../../../../shared/design_system/tokens/app_colors.dart'; -/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste) +/// UnionFlow — Écran de connexion premium +/// Gradient forêt + glassmorphism + animations + biométrie + remember me class LoginPage extends StatefulWidget { - const LoginPage({Key? key}) : super(key: key); + const LoginPage({super.key}); @override State createState() => _LoginPageState(); } -class _LoginPageState extends State { +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(); + + static const _gradTop = Color(0xFF1B5E20); + static const _gradMid = Color(0xFF2E7D32); + static const _gradBot = Color(0xFF388E3C); + static const _primaryGreen = Color(0xFF2E7D32); + + @override + void initState() { + super.initState(); + _fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 900)); + _slideController = AnimationController(vsync: this, duration: const Duration(milliseconds: 750)); + _fadeAnim = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut); + _slideAnim = Tween(begin: const Offset(0, 0.12), end: Offset.zero) + .animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic)); + _fadeController.forward(); + _slideController.forward(); + _checkBiometrics(); + _loadSavedCredentials(); + } + @override void dispose() { + _fadeController.dispose(); + _slideController.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } - Future _openForgotPassword(BuildContext context) async { + Future _checkBiometrics() async { + try { + final canCheck = await _localAuth.canCheckBiometrics; + final supported = await _localAuth.isDeviceSupported(); + if (mounted) setState(() => _biometricAvailable = canCheck && supported); + } 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( + localizedReason: 'Authentifiez-vous pour accéder à UnionFlow', + 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)); + } + } + } catch (_) {} + } + + Future _openForgotPassword() async { final url = Uri.parse( '${AppConfig.keycloakRealmUrl}/protocol/openid-connect/auth' '?client_id=unionflow-mobile' '&redirect_uri=${Uri.encodeComponent('http://localhost')}' - '&response_type=code' - '&scope=openid' - '&kc_action=reset_credentials', + '&response_type=code&scope=openid&kc_action=reset_credentials', ); try { - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')), - ); - } - } + if (await canLaunchUrl(url)) await launchUrl(url, mode: LaunchMode.externalApplication); + } catch (_) {} } - void _onLogin() { - final email = _emailController.text; + Future _onLogin() async { + final email = _emailController.text.trim(); final password = _passwordController.text; + if (email.isEmpty || password.isEmpty) return; - if (email.isNotEmpty && password.isNotEmpty) { - context.read().add(AuthLoginRequested(email, password)); + 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)); } @override @@ -70,100 +135,433 @@ class _LoginPageState extends State { return Scaffold( body: BlocConsumer( listener: (context, state) { - if (state is AuthAuthenticated) { - // Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout - } else if (state is AuthError) { + if (state is AuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.message, style: AppTypography.bodyTextSmall), - backgroundColor: AppColors.error, + content: Text(state.message), + backgroundColor: const Color(0xFFB71C1C), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ); } }, builder: (context, state) { final isLoading = state is AuthLoading; - - return SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Logo minimaliste (Texte seul) - Center( - child: Text( - 'UnionFlow', - style: AppTypography.headerSmall.copyWith( - fontSize: 24, // Exception unique pour le logo - color: AppColors.primaryGreen, - letterSpacing: 1.2, - ), - ), - ), - const SizedBox(height: 8), - Center( - child: Text( - 'Connexion à votre espace.', - style: AppTypography.subtitleSmall, - ), - ), - const SizedBox(height: 48), - - // Champs de texte DRY - CoreTextField( - controller: _emailController, - hintText: 'Email ou Identifiant', - prefixIcon: Icons.person_outline, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 16), - CoreTextField( - controller: _passwordController, - hintText: 'Mot de passe', - prefixIcon: Icons.lock_outline, - obscureText: true, - ), - - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => _openForgotPassword(context), - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 0), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'Oublié ?', - style: AppTypography.subtitleSmall.copyWith( - color: AppColors.primaryGreen, - ), - ), - ), - ), - const SizedBox(height: 32), - - // Bouton centralisé avec chargement intégré - Center( - child: isLoading - ? const CircularProgressIndicator(color: AppColors.primaryGreen) - : DynamicFAB( - icon: Icons.arrow_forward, - label: 'Se Connecter', - onPressed: _onLogin, - ), - ), - ], + return Stack( + children: [ + // Gradient background + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_gradTop, _gradMid, _gradBot], + stops: [0.0, 0.55, 1.0], + ), ), ), - ), + + // Subtle hexagon pattern overlay + const Positioned.fill(child: _HexPatternOverlay()), + + // Content + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _slideAnim, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLogoSection(), + const SizedBox(height: 16), + _buildGlassCard(isLoading), + ], + ), + ), + ), + ), + ), + ), + ], ); }, ), ); } + + Widget _buildLogoSection() { + return Column( + children: [ + CustomPaint( + size: const Size(48, 48), + painter: _HexLogoMark(), + ), + const SizedBox(height: 10), + Text( + 'UnionFlow', + style: GoogleFonts.roboto( + fontSize: 34, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Gérez votre organisation avec sérénité', + style: GoogleFonts.roboto( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white.withOpacity(0.78), + letterSpacing: 0.2, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildGlassCard(bool isLoading) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.11), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.22), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 40, + offset: const Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Connexion', + style: GoogleFonts.roboto( + fontSize: 22, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: 0.1, + ), + ), + const SizedBox(height: 4), + Text( + 'Accédez à votre espace de travail', + style: GoogleFonts.roboto( + fontSize: 12.5, + color: Colors.white.withOpacity(0.68), + letterSpacing: 0.1, + ), + ), + 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), + ), + 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), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Login button + isLoading + ? const Center(child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) + : ElevatedButton( + onPressed: _onLogin, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: _primaryGreen, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + child: Text( + 'Se connecter', + style: GoogleFonts.roboto( + fontSize: 15.5, + fontWeight: FontWeight.w700, + color: _primaryGreen, + letterSpacing: 0.2, + ), + ), + ), + + // Biometric + if (_biometricAvailable) ...[ + const SizedBox(height: 14), + Center( + child: TextButton.icon( + onPressed: _authenticateBiometric, + icon: const Icon(Icons.fingerprint_rounded, color: Colors.white60, size: 22), + label: Text( + 'Connexion biométrique', + style: GoogleFonts.roboto(fontSize: 12.5, color: Colors.white60), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ), + ), + ), + ], + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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 +// ───────────────────────────────────────────────────────────────────────────── + +/// Motif hexagonal en overlay sur le dégradé (opacité 4%) +class _HexPatternOverlay extends StatelessWidget { + const _HexPatternOverlay(); + + @override + Widget build(BuildContext context) { + return CustomPaint(painter: _HexPatternPainter()); + } +} + +class _HexPatternPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withOpacity(0.045) + ..style = PaintingStyle.stroke + ..strokeWidth = 0.8; + + const r = 22.0; + const hSpace = r * 1.75; + const vSpace = r * 1.52; + + for (double row = -1; row * vSpace < size.height + vSpace; row++) { + final offset = (row % 2 == 0) ? 0.0 : hSpace / 2; + for (double col = -1; col * hSpace - offset < size.width + hSpace; col++) { + _hexagon(canvas, paint, Offset(col * hSpace + offset, row * vSpace), r); + } + } + } + + void _hexagon(Canvas canvas, Paint paint, Offset center, double r) { + final path = Path(); + for (int i = 0; i < 6; i++) { + final a = (i * 60 - 30) * math.pi / 180; + final p = Offset(center.dx + r * math.cos(a), center.dy + r * math.sin(a)); + if (i == 0) path.moveTo(p.dx, p.dy); else path.lineTo(p.dx, p.dy); + } + path.close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Logo hexagonal avec initiales "UF" +class _HexLogoMark extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + final r = size.width / 2 - 2; + + // Fond hexagonal blanc semi-transparent + final bgPaint = Paint() + ..color = Colors.white.withOpacity(0.18) + ..style = PaintingStyle.fill; + final borderPaint = Paint() + ..color = Colors.white.withOpacity(0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8; + + final path = Path(); + for (int i = 0; i < 6; i++) { + final a = (i * 60 - 30) * math.pi / 180; + final p = Offset(cx + r * math.cos(a), cy + r * math.sin(a)); + if (i == 0) path.moveTo(p.dx, p.dy); else path.lineTo(p.dx, p.dy); + } + path.close(); + canvas.drawPath(path, bgPaint); + canvas.drawPath(path, borderPaint); + + // Lignes stylisées "UF" dessinées (plus propre qu'un TextPainter dans un painter) + final linePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + // Lettre U + final uPath = Path() + ..moveTo(cx - 13, cy - 10) + ..lineTo(cx - 13, cy + 5) + ..quadraticBezierTo(cx - 13, cy + 12, cx - 7, cy + 12) + ..quadraticBezierTo(cx - 1, cy + 12, cx - 1, cy + 5) + ..lineTo(cx - 1, cy - 10); + canvas.drawPath(uPath, linePaint); + + // Lettre F + canvas.drawLine(Offset(cx + 3, cy - 10), Offset(cx + 3, cy + 12), linePaint); + canvas.drawLine(Offset(cx + 3, cy - 10), Offset(cx + 13, cy - 10), linePaint); + canvas.drawLine(Offset(cx + 3, cy + 1), Offset(cx + 11, cy + 1), linePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/lib/features/backup/presentation/bloc/backup_bloc.dart b/lib/features/backup/presentation/bloc/backup_bloc.dart index 4edd563..dce2f92 100644 --- a/lib/features/backup/presentation/bloc/backup_bloc.dart +++ b/lib/features/backup/presentation/bloc/backup_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des sauvegardes library backup_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:equatable/equatable.dart'; @@ -105,6 +106,7 @@ class BackupBloc extends Bloc { final backups = await _repository.getAll(); emit(BackupsLoaded(backups)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } @@ -117,6 +119,7 @@ class BackupBloc extends Bloc { emit(BackupsLoaded(backups)); emit(BackupSuccess('Sauvegarde créée')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } @@ -127,6 +130,7 @@ class BackupBloc extends Bloc { await _repository.restore(event.backupId); emit(BackupSuccess('Restauration en cours')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } @@ -139,6 +143,7 @@ class BackupBloc extends Bloc { emit(BackupsLoaded(backups)); emit(BackupSuccess('Sauvegarde supprimée')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } @@ -149,6 +154,7 @@ class BackupBloc extends Bloc { final config = await _repository.getConfig(); emit(BackupConfigLoaded(config)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } @@ -160,6 +166,7 @@ class BackupBloc extends Bloc { emit(BackupConfigLoaded(config)); emit(BackupSuccess('Configuration mise à jour')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(BackupError('Erreur: ${e.toString()}')); } } diff --git a/lib/features/backup/presentation/pages/backup_page.dart b/lib/features/backup/presentation/pages/backup_page.dart index 1954e5a..89e45d2 100644 --- a/lib/features/backup/presentation/pages/backup_page.dart +++ b/lib/features/backup/presentation/pages/backup_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/tokens/color_tokens.dart'; import '../../../../shared/design_system/tokens/spacing_tokens.dart'; import '../../../../core/di/injection_container.dart'; @@ -65,7 +66,7 @@ class _BackupPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -73,7 +74,7 @@ class _BackupPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.error), - backgroundColor: const Color(0xFFD63031), + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); @@ -81,7 +82,7 @@ class _BackupPageState extends State }, builder: (context, state) { return Scaffold( - backgroundColor: ColorTokens.background, + backgroundColor: AppColors.lightBackground, body: Column( children: [ _buildHeader(), @@ -107,8 +108,8 @@ class _BackupPageState extends State /// Header harmonisé Widget _buildHeader() { return Container( - margin: const EdgeInsets.all(SpacingTokens.lg), - padding: const EdgeInsets.all(SpacingTokens.xl), + margin: const EdgeInsets.symmetric(horizontal: SpacingTokens.sm, vertical: SpacingTokens.xs), + padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( gradient: const LinearGradient( colors: ColorTokens.primaryGradient, @@ -129,18 +130,18 @@ class _BackupPageState extends State Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.backup, color: Colors.white, - size: 24, + size: 20, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -314,9 +315,9 @@ class _BackupPageState extends State ), child: TabBar( controller: _tabController, - labelColor: const Color(0xFF6C5CE7), + labelColor: AppColors.primaryGreen, unselectedLabelColor: Colors.grey[600], - indicatorColor: const Color(0xFF6C5CE7), + indicatorColor: AppColors.primaryGreen, indicatorWeight: 3, labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12), tabs: const [ @@ -334,7 +335,7 @@ class _BackupPageState extends State padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), state is BackupLoading ? const Center(child: CircularProgressIndicator()) : _buildBackupsList(state is BackupsLoaded ? state.backups : (_cachedBackups ?? [])), @@ -372,7 +373,7 @@ class _BackupPageState extends State children: [ Row( children: [ - const Icon(Icons.folder, color: Color(0xFF6C5CE7), size: 20), + const Icon(Icons.folder, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 8), Text( 'Sauvegardes disponibles', @@ -404,7 +405,7 @@ class _BackupPageState extends State children: [ Icon( backup['type'] == 'Auto' ? Icons.schedule : Icons.touch_app, - color: backup['type'] == 'Auto' ? Colors.blue : Colors.green, + color: backup['type'] == 'Auto' ? AppColors.primaryGreen : AppColors.success, size: 20, ), const SizedBox(width: 12), @@ -417,7 +418,7 @@ class _BackupPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), Text( @@ -478,7 +479,7 @@ class _BackupPageState extends State children: [ Row( children: [ - const Icon(Icons.schedule, color: Color(0xFF6C5CE7), size: 20), + const Icon(Icons.schedule, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 8), Text( 'Configuration automatique', @@ -550,7 +551,7 @@ class _BackupPageState extends State children: [ Row( children: [ - const Icon(Icons.restore, color: Color(0xFF6C5CE7), size: 20), + const Icon(Icons.restore, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 8), Text( 'Options de restauration', @@ -567,7 +568,7 @@ class _BackupPageState extends State 'Restaurer depuis un fichier', 'Importer une sauvegarde externe', Icons.file_upload, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _restoreFromFile(), ), const SizedBox(height: 12), @@ -575,7 +576,7 @@ class _BackupPageState extends State 'Restauration sélective', 'Restaurer uniquement certaines données', Icons.checklist, - const Color(0xFF00B894), + AppColors.success, () => _selectiveRestore(), ), const SizedBox(height: 12), @@ -583,7 +584,7 @@ class _BackupPageState extends State 'Point de restauration', 'Créer un point de restauration avant modification', Icons.bookmark, - const Color(0xFFE17055), + AppColors.warning, () => _createRestorePoint(), ), ], @@ -604,7 +605,7 @@ class _BackupPageState extends State ], ), ), - Switch(value: value, onChanged: onChanged, activeColor: const Color(0xFF6C5CE7)), + Switch(value: value, onChanged: onChanged, activeColor: AppColors.primaryGreen), ], ); } @@ -708,7 +709,7 @@ class _BackupPageState extends State AppLogger.error('BackupPage: téléchargement échoué', error: e, stackTrace: st); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Impossible de récupérer la sauvegarde.'), backgroundColor: Color(0xFFD63031)), + const SnackBar(content: Text('Impossible de récupérer la sauvegarde.'), backgroundColor: AppColors.error), ); } } @@ -731,7 +732,7 @@ class _BackupPageState extends State AppLogger.error('BackupPage: restauration depuis fichier', error: e, stackTrace: st); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sélection de fichier impossible.'), backgroundColor: Color(0xFFD63031)), + const SnackBar(content: Text('Sélection de fichier impossible.'), backgroundColor: AppColors.error), ); } } @@ -755,7 +756,7 @@ class _BackupPageState extends State AppLogger.error('BackupPage: restauration sélective', error: e, stackTrace: st); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sélection impossible.'), backgroundColor: Color(0xFFD63031)), + const SnackBar(content: Text('Sélection impossible.'), backgroundColor: AppColors.error), ); } } @@ -767,7 +768,7 @@ class _BackupPageState extends State void _showSuccessSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: const Color(0xFF00B894), behavior: SnackBarBehavior.floating), + SnackBar(content: Text(message), backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating), ); } } diff --git a/lib/features/communication/data/datasources/messaging_remote_datasource.dart b/lib/features/communication/data/datasources/messaging_remote_datasource.dart index 0e54290..cc1d043 100644 --- a/lib/features/communication/data/datasources/messaging_remote_datasource.dart +++ b/lib/features/communication/data/datasources/messaging_remote_datasource.dart @@ -23,7 +23,7 @@ class MessagingRemoteDatasource { /// Headers HTTP avec authentification Future> _getHeaders() async { - final token = await secureStorage.read(key: 'access_token'); + final token = await secureStorage.read(key: 'kc_access'); return { 'Content-Type': 'application/json', 'Accept': 'application/json', diff --git a/lib/features/contributions/bloc/contributions_bloc.dart b/lib/features/contributions/bloc/contributions_bloc.dart index 6153c0b..884fd6d 100644 --- a/lib/features/contributions/bloc/contributions_bloc.dart +++ b/lib/features/contributions/bloc/contributions_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des contributions library contributions_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../../../core/utils/logger.dart'; @@ -82,6 +83,7 @@ class ContributionsBloc extends Bloc { 'total': result.total, }); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur lors du chargement des contributions', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors du chargement des contributions', error: e)); } @@ -96,6 +98,7 @@ class ContributionsBloc extends Bloc { final contribution = await _getContributionById(event.id); emit(ContributionDetailLoaded(contribution: contribution)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Contribution non trouvée', error: e)); } @@ -110,6 +113,7 @@ class ContributionsBloc extends Bloc { final created = await _createContribution(event.contribution); emit(ContributionCreated(contribution: created)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors de la création de la contribution', error: e)); } @@ -124,6 +128,7 @@ class ContributionsBloc extends Bloc { final updated = await _updateContribution(event.id, event.contribution); emit(ContributionUpdated(contribution: updated)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors de la mise à jour', error: e)); } @@ -138,6 +143,7 @@ class ContributionsBloc extends Bloc { await _deleteContribution(event.id); emit(ContributionDeleted(id: event.id)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors de la suppression', error: e)); } @@ -167,6 +173,7 @@ class ContributionsBloc extends Bloc { totalPages: result.totalPages, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors de la recherche', error: e)); } @@ -193,6 +200,7 @@ class ContributionsBloc extends Bloc { totalPages: result.totalPages, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors du chargement', error: e)); } @@ -214,6 +222,7 @@ class ContributionsBloc extends Bloc { totalPages: payees.isEmpty ? 0 : 1, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } @@ -235,6 +244,7 @@ class ContributionsBloc extends Bloc { totalPages: nonPayees.isEmpty ? 0 : 1, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } @@ -256,6 +266,7 @@ class ContributionsBloc extends Bloc { totalPages: enRetard.isEmpty ? 0 : 1, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } @@ -279,6 +290,7 @@ class ContributionsBloc extends Bloc { emit(PaymentRecorded(contribution: updated)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur lors de l\'enregistrement du paiement', error: e)); } @@ -306,6 +318,7 @@ class ContributionsBloc extends Bloc { contributions: contributions, )); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } @@ -345,6 +358,7 @@ class ContributionsBloc extends Bloc { final count = await _repository.genererCotisationsAnnuelles(event.annee); emit(ContributionsGenerated(nombreGenere: count)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } @@ -359,6 +373,7 @@ class ContributionsBloc extends Bloc { await _repository.envoyerRappel(event.contributionId); emit(ReminderSent(contributionId: event.contributionId)); } catch (e, stackTrace) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('Erreur', error: e, stackTrace: stackTrace); emit(ContributionsError(message: 'Erreur', error: e)); } diff --git a/lib/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart b/lib/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart index 3774dee..4420446 100644 --- a/lib/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart +++ b/lib/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/utils/logger.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../../../shared/widgets/loading_widget.dart'; @@ -91,22 +92,22 @@ class _MesStatistiquesCotisationsPageState extends State 0 ? UnionFlowColors.terracotta : UnionFlowColors.success, + color: montantDu > 0 ? UnionFlowColors.terracotta : AppColors.success, ), ), const SizedBox(width: 12), @@ -157,7 +158,7 @@ class _MesStatistiquesCotisationsPageState extends State 0 ? UnionFlowColors.gold : UnionFlowColors.success, + color: enAttente > 0 ? UnionFlowColors.gold : AppColors.success, ), ), const SizedBox(width: 12), @@ -192,10 +193,10 @@ class _MesStatistiquesCotisationsPageState extends State 0 ? (totalPayeAnnee / total * 100) : 0.0; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: ColorTokens.surface, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(10), border: Border.all(color: ColorTokens.outline), boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))], ), @@ -260,7 +261,7 @@ class _MesStatistiquesCotisationsPageState extends State( - taux >= 75 ? UnionFlowColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta), + taux >= 75 ? AppColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta), ), ), ), @@ -271,7 +272,7 @@ class _MesStatistiquesCotisationsPageState extends State[]; if (paye > 0) { sections.add(PieChartSectionData( - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, value: paye, title: 'Payé', radius: 60, @@ -312,10 +313,10 @@ class _MesStatistiquesCotisationsPageState extends State AppConfig.apiBaseUrl; diff --git a/lib/features/dashboard/data/datasources/dashboard_remote_datasource.dart b/lib/features/dashboard/data/datasources/dashboard_remote_datasource.dart index 8323b70..05b3999 100644 --- a/lib/features/dashboard/data/datasources/dashboard_remote_datasource.dart +++ b/lib/features/dashboard/data/datasources/dashboard_remote_datasource.dart @@ -45,7 +45,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { } } on DioException catch (e) { AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e, stackTrace: st); rethrow; @@ -68,7 +68,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { throw const NotFoundException('Profil membre non trouvé'); } AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e, stackTrace: st); rethrow; @@ -109,7 +109,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { throw const NotFoundException('Compte adhérent non trouvé'); } AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e, stackTrace: st); rethrow; @@ -135,7 +135,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { } } on DioException catch (e) { AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e, stackTrace: st); rethrow; @@ -166,7 +166,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { } } on DioException catch (e) { AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e, stackTrace: st); rethrow; @@ -197,7 +197,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource { } } on DioException catch (e) { AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e); - throw ServerException('Network error: ${e.message}'); + rethrow; } catch (e, st) { AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e, stackTrace: st); rethrow; diff --git a/lib/features/dashboard/data/repositories/dashboard_repository_impl.dart b/lib/features/dashboard/data/repositories/dashboard_repository_impl.dart index 69ed22d..dcd062d 100644 --- a/lib/features/dashboard/data/repositories/dashboard_repository_impl.dart +++ b/lib/features/dashboard/data/repositories/dashboard_repository_impl.dart @@ -47,14 +47,16 @@ class DashboardRepositoryImpl implements DashboardRepository { @override Future> getDashboardData( String organizationId, - String userId, - ) async { + String userId, { + bool useGlobalDashboard = false, + }) async { if (!await networkInfo.isConnected) { return const Left(NetworkFailure('No internet connection')); } try { // Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me) - final useMemberDashboard = organizationId.trim().isEmpty; + // useGlobalDashboard=true signifie qu'on veut les stats globales même sans orgId (ex: SUPER_ADMIN) + final useMemberDashboard = !useGlobalDashboard && organizationId.trim().isEmpty; if (useMemberDashboard) { // Chargement parallèle de la synthèse et du compte adhérent unifié MembreDashboardSyntheseModel? synthese; @@ -309,8 +311,8 @@ class DashboardRepositoryImpl implements DashboardRepository { monthlyGrowth: model.monthlyGrowth, engagementRate: model.engagementRate, lastUpdated: model.lastUpdated, - totalOrganizations: null, - organizationTypeDistribution: null, + totalOrganizations: model.totalOrganizations, + organizationTypeDistribution: model.organizationTypeDistribution, ); } diff --git a/lib/features/dashboard/data/repositories/finance_repository.dart b/lib/features/dashboard/data/repositories/finance_repository.dart index 88720ee..486216b 100644 --- a/lib/features/dashboard/data/repositories/finance_repository.dart +++ b/lib/features/dashboard/data/repositories/finance_repository.dart @@ -33,6 +33,7 @@ class FinanceRepository { epargneBalance: epargneBalance, ); } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('FinanceRepository: getFinancialSummary échoué', error: e, stackTrace: st); rethrow; } catch (e, st) { @@ -50,6 +51,7 @@ class FinanceRepository { .map((json) => _transactionFromJson(json as Map)) .toList(); } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('FinanceRepository: getTransactions échoué', error: e, stackTrace: st); if (e.response?.statusCode == 404) return []; rethrow; diff --git a/lib/features/dashboard/domain/repositories/dashboard_repository.dart b/lib/features/dashboard/domain/repositories/dashboard_repository.dart index 602361b..7f8d619 100644 --- a/lib/features/dashboard/domain/repositories/dashboard_repository.dart +++ b/lib/features/dashboard/domain/repositories/dashboard_repository.dart @@ -9,8 +9,9 @@ abstract class DashboardRepository { Future> getDashboardData( String organizationId, - String userId, - ); + String userId, { + bool useGlobalDashboard = false, + }); Future> getDashboardStats( String organizationId, diff --git a/lib/features/dashboard/domain/usecases/get_dashboard_data.dart b/lib/features/dashboard/domain/usecases/get_dashboard_data.dart index a442a41..99adac9 100644 --- a/lib/features/dashboard/domain/usecases/get_dashboard_data.dart +++ b/lib/features/dashboard/domain/usecases/get_dashboard_data.dart @@ -17,6 +17,7 @@ class GetDashboardData implements UseCase get props => [organizationId, userId]; + List get props => [organizationId, userId, useGlobalDashboard]; } @injectable diff --git a/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart b/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart index 2530b19..05d49d8 100644 --- a/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart +++ b/lib/features/dashboard/presentation/bloc/dashboard_bloc.dart @@ -90,6 +90,7 @@ class DashboardBloc extends Bloc { GetDashboardDataParams( organizationId: event.organizationId, userId: event.userId, + useGlobalDashboard: event.useGlobalDashboard, ), ); @@ -120,6 +121,7 @@ class DashboardBloc extends Bloc { GetDashboardDataParams( organizationId: event.organizationId, userId: event.userId, + useGlobalDashboard: event.useGlobalDashboard, ), ); diff --git a/lib/features/dashboard/presentation/bloc/dashboard_event.dart b/lib/features/dashboard/presentation/bloc/dashboard_event.dart index 5f58a9f..75baffb 100644 --- a/lib/features/dashboard/presentation/bloc/dashboard_event.dart +++ b/lib/features/dashboard/presentation/bloc/dashboard_event.dart @@ -10,27 +10,33 @@ abstract class DashboardEvent extends Equatable { class LoadDashboardData extends DashboardEvent { final String organizationId; final String userId; + /// Si true, utilise le dashboard global (stats toutes orgs) même quand organizationId est vide. + /// Utilisé pour SUPER_ADMIN qui n'appartient pas à une org. + final bool useGlobalDashboard; const LoadDashboardData({ required this.organizationId, required this.userId, + this.useGlobalDashboard = false, }); @override - List get props => [organizationId, userId]; + List get props => [organizationId, userId, useGlobalDashboard]; } class RefreshDashboardData extends DashboardEvent { final String organizationId; final String userId; + final bool useGlobalDashboard; const RefreshDashboardData({ required this.organizationId, required this.userId, + this.useGlobalDashboard = false, }); @override - List get props => [organizationId, userId]; + List get props => [organizationId, userId, useGlobalDashboard]; } class LoadDashboardStats extends DashboardEvent { diff --git a/lib/features/dashboard/presentation/bloc/finance_bloc.dart b/lib/features/dashboard/presentation/bloc/finance_bloc.dart index 9499be3..e29f275 100644 --- a/lib/features/dashboard/presentation/bloc/finance_bloc.dart +++ b/lib/features/dashboard/presentation/bloc/finance_bloc.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; @@ -21,6 +22,7 @@ class FinanceBloc extends Bloc { final transactions = await _repository.getTransactions(); emit(FinanceLoaded(summary: summary, transactions: transactions)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(FinanceError('Erreur chargement des finances: $e')); } } diff --git a/lib/features/dashboard/presentation/pages/advanced_dashboard_page.dart b/lib/features/dashboard/presentation/pages/advanced_dashboard_page.dart index c01b78e..a8d4628 100644 --- a/lib/features/dashboard/presentation/pages/advanced_dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/advanced_dashboard_page.dart @@ -59,7 +59,7 @@ class _AdvancedDashboardPageState extends State return BlocProvider( create: (context) => _dashboardBloc, child: Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: AppColors.lightBackground, appBar: const UFAppBar( title: 'DASHBOARD AVANCÉ', ), @@ -146,7 +146,7 @@ class _AdvancedDashboardPageState extends State ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), BlocBuilder( builder: (context, state) { if (state is DashboardLoaded || state is DashboardRefreshing) { @@ -281,7 +281,7 @@ class _AdvancedDashboardPageState extends State onRefresh: () async => _refreshDashboardData(), color: AppColors.primaryGreen, child: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( children: [ // Métriques temps réel @@ -289,15 +289,15 @@ class _AdvancedDashboardPageState extends State organizationId: widget.organizationId, userId: widget.userId, ), - const SizedBox(height: 16), + const SizedBox(height: 8), // Grille de statistiques _buildStatsGrid(), - const SizedBox(height: 16), + const SizedBox(height: 8), // Notifications const DashboardNotificationsWidget(maxNotifications: 3), - const SizedBox(height: 16), + const SizedBox(height: 8), // Activités et événements const Row( @@ -360,7 +360,7 @@ class _AdvancedDashboardPageState extends State Widget _buildReportsTab() { return SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( children: [ _buildReportCard( @@ -402,7 +402,7 @@ class _AdvancedDashboardPageState extends State physics: const NeverScrollableScrollPhysics(), crossAxisSpacing: 12, mainAxisSpacing: 12, - childAspectRatio: 1.25, + childAspectRatio: 2.0, children: [ ConnectedStatsCard( title: 'Membres', diff --git a/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart b/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart index e91a872..526250c 100644 --- a/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/connected_dashboard_page.dart @@ -49,14 +49,14 @@ class _ConnectedDashboardPageState extends State with Si @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: AppColors.lightBackground, appBar: _buildAppBar(), body: AfricanPatternBackground( child: BlocBuilder( builder: (context, state) { if (state is DashboardLoading) { return const Center( - child: CircularProgressIndicator(color: UnionFlowColors.unionGreen), + child: CircularProgressIndicator(color: AppColors.primaryGreen), ); } @@ -81,7 +81,7 @@ class _ConnectedDashboardPageState extends State with Si PreferredSizeWidget _buildAppBar() { return AppBar( - backgroundColor: UnionFlowColors.surface, + backgroundColor: AppColors.lightSurface, elevation: 0, title: Row( children: [ @@ -114,7 +114,7 @@ class _ConnectedDashboardPageState extends State with Si style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), Text( @@ -122,7 +122,7 @@ class _ConnectedDashboardPageState extends State with Si style: TextStyle( fontSize: 11, fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), ), ], @@ -148,7 +148,7 @@ class _ConnectedDashboardPageState extends State with Si count: _unreadNotifications, child: IconButton( icon: const Icon(Icons.notifications_outlined), - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, onPressed: () { setState(() => _unreadNotifications = 0); UnionNotificationToast.show( @@ -165,9 +165,9 @@ class _ConnectedDashboardPageState extends State with Si ], bottom: TabBar( controller: _tabController, - labelColor: UnionFlowColors.unionGreen, - unselectedLabelColor: UnionFlowColors.textSecondary, - indicatorColor: UnionFlowColors.unionGreen, + labelColor: AppColors.primaryGreen, + unselectedLabelColor: AppColors.textSecondaryLight, + indicatorColor: AppColors.primaryGreen, labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), tabs: const [ Tab(text: 'Vue d\'ensemble'), @@ -188,7 +188,7 @@ class _ConnectedDashboardPageState extends State with Si userId: widget.userId, )); }, - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, child: TabBarView( controller: _tabController, children: [ @@ -216,7 +216,7 @@ class _ConnectedDashboardPageState extends State with Si final stats = data.stats; return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -242,7 +242,7 @@ class _ConnectedDashboardPageState extends State with Si label: 'Membres', value: stats.totalMembers.toString(), icon: Icons.people_outline, - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, trend: '+8%', isTrendUp: true, ), @@ -288,7 +288,7 @@ class _ConnectedDashboardPageState extends State with Si ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Progression - Animée AnimatedFadeIn( @@ -299,7 +299,7 @@ class _ConnectedDashboardPageState extends State with Si subtitle: '70% des membres ont cotisé ce mois', ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Actions rapides - Animées AnimatedSlideIn( @@ -316,7 +316,7 @@ class _ConnectedDashboardPageState extends State with Si ); }, backgroundColor: UnionFlowColors.unionGreenPale, - iconColor: UnionFlowColors.unionGreen, + iconColor: AppColors.primaryGreen, ), UnionActionButton( icon: Icons.send, @@ -354,7 +354,7 @@ class _ConnectedDashboardPageState extends State with Si ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Activité récente - Animée AnimatedFadeIn( @@ -382,7 +382,7 @@ class _ConnectedDashboardPageState extends State with Si final taux = (stats.engagementRate * 100).toStringAsFixed(0); return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -398,7 +398,7 @@ class _ConnectedDashboardPageState extends State with Si title: 'Période mise à jour', message: 'Affichage pour ${period.label.toLowerCase()}', icon: Icons.calendar_today, - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, ); }, ), @@ -425,7 +425,7 @@ class _ConnectedDashboardPageState extends State with Si sections: [ UnionPieChartSection.create( value: 40, - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, title: '40%\nCotisations', ), UnionPieChartSection.create( @@ -446,7 +446,7 @@ class _ConnectedDashboardPageState extends State with Si ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Titre AnimatedFadeIn( @@ -454,13 +454,13 @@ class _ConnectedDashboardPageState extends State with Si child: const Text( 'Métriques Financières', style: TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), // Métriques - Animées (données backend) AnimatedSlideIn( @@ -523,7 +523,7 @@ class _ConnectedDashboardPageState extends State with Si final tiles = data.recentActivities.map((a) => _activityToTile(a)).toList(); return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -582,21 +582,20 @@ class _ConnectedDashboardPageState extends State with Si Widget _buildFinanceMetric(String label, String value, IconData icon, Color color) { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + color: AppColors.lightSurface, + borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, size: 24, color: color), + child: Icon(icon, size: 16, color: color), ), const SizedBox(width: 12), Expanded( @@ -608,14 +607,14 @@ class _ConnectedDashboardPageState extends State with Si style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), ), const SizedBox(height: 4), Text( value, style: TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w700, color: color, ), @@ -631,62 +630,62 @@ class _ConnectedDashboardPageState extends State with Si Widget _buildMemberNotRegisteredState() { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - padding: const EdgeInsets.all(28), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle, ), child: const Icon( Icons.person_add_alt_1_outlined, - size: 56, + size: 36, color: Colors.white, ), ), - const SizedBox(height: 28), + const SizedBox(height: 14), const Text( 'Bienvenue dans UnionFlow', style: TextStyle( - fontSize: 20, + fontSize: 15, fontWeight: FontWeight.w800, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), textAlign: TextAlign.center, ), - const SizedBox(height: 12), + 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: 14, - color: UnionFlowColors.textSecondary, + fontSize: 12, + color: AppColors.textSecondaryLight, height: 1.5, ), textAlign: TextAlign.center, ), - const SizedBox(height: 32), + const SizedBox(height: 16), Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: UnionFlowColors.unionGreen.withOpacity(0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.unionGreen.withOpacity(0.3)), + 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: UnionFlowColors.unionGreen), + 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: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, fontWeight: FontWeight.w500, ), ), @@ -707,24 +706,24 @@ class _ConnectedDashboardPageState extends State with Si mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: UnionFlowColors.errorPale, shape: BoxShape.circle, ), child: const Icon( Icons.error_outline, - size: 64, + size: 40, color: UnionFlowColors.error, ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), const Text( 'Erreur de chargement', style: TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), const SizedBox(height: 8), @@ -732,7 +731,7 @@ class _ConnectedDashboardPageState extends State with Si message, style: const TextStyle( fontSize: 13, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), textAlign: TextAlign.center, ), diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart index 13f2c4a..f23a5ed 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart @@ -20,12 +20,8 @@ class ActiveMemberDashboard extends StatelessWidget { backgroundColor: UnionFlowColors.background, appBar: _buildAppBar(), drawer: DashboardDrawer( - onNavigate: (route) { - Navigator.of(context).pushNamed(route); - }, - onLogout: () { - context.read().add(const AuthLogoutRequested()); - }, + onNavigate: (route) => Navigator.of(context).pushNamed(route), + onLogout: () => context.read().add(const AuthLogoutRequested()), ), body: AfricanPatternBackground( child: BlocBuilder( @@ -46,18 +42,25 @@ class ActiveMemberDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête AnimatedFadeIn( delay: const Duration(milliseconds: 100), - child: _buildUserHeader(user), + child: UserIdentityCard( + initials: user?.initials ?? 'MA', + name: user?.fullName ?? 'Membre Actif', + subtitle: 'Depuis ${user?.createdAt.year ?? 2024} · Très Actif', + badgeLabel: 'ACTIF', + gradient: UnionFlowColors.warmGradient, + accentColor: UnionFlowColors.gold, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Balance principale ou Vue Unifiée (Compte Adhérent) + // Balance / Vue Unifiée AnimatedSlideIn( delay: const Duration(milliseconds: 200), child: dashboardData?.monCompte != null @@ -73,309 +76,147 @@ class ActiveMemberDashboard extends StatelessWidget { label: 'Mon Solde Total', amount: _formatAmount(stats?.totalContributionAmount ?? 0), trend: stats != null && stats.monthlyGrowth != 0 - ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}% ce mois' - : 'Aucune variation', + ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, ), ), + const SizedBox(height: 12), - const SizedBox(height: 24), - - // Bloc KPI unifié (4 stats regroupées) + // KPIs AnimatedFadeIn( delay: const Duration(milliseconds: 300), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Cotisations', - value: '${stats?.totalContributions ?? 0}', - icon: Icons.check_circle, - color: (stats?.totalContributions ?? 0) > 0 - ? UnionFlowColors.success - : UnionFlowColors.textTertiary, - trend: stats != null && stats.totalContributions > 0 && stats.engagementRate > 0 - ? (stats.engagementRate >= 1.0 - ? 'Tout payé' - : '${(stats.engagementRate * 100).toStringAsFixed(0)}% payé') - : null, - isTrendUp: (stats?.engagementRate ?? 0) >= 1.0, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Engagement', - value: stats != null && stats.engagementRate > 0 - ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' - : stats != null && stats.totalContributions > 0 - ? '—' - : '0%', - icon: Icons.trending_up, - color: UnionFlowColors.gold, - trend: stats != null && stats.engagementRate > 0.9 - ? 'Excellent' - : stats != null && stats.engagementRate > 0.5 - ? 'Bon' - : null, - isTrendUp: (stats?.engagementRate ?? 0) > 0.7, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Contribution Totale', - value: _formatAmount(stats?.contributionsAmountOnly ?? stats?.totalContributionAmount ?? 0), - icon: Icons.savings, - color: UnionFlowColors.amber, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event_available, - color: UnionFlowColors.terracotta, - ), - ), - ], - ), - ], - ), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, + children: [ + UnionStatWidget( + label: 'Cotisations', + value: '${stats?.totalContributions ?? 0}', + icon: Icons.check_circle, + color: (stats?.totalContributions ?? 0) > 0 + ? UnionFlowColors.success + : UnionFlowColors.textTertiary, + trend: stats != null && stats.totalContributions > 0 && stats.engagementRate > 0 + ? (stats.engagementRate >= 1.0 + ? 'Tout payé' + : '${(stats.engagementRate * 100).toStringAsFixed(0)}% payé') + : null, + isTrendUp: (stats?.engagementRate ?? 0) >= 1.0, + ), + UnionStatWidget( + label: 'Engagement', + value: stats != null && stats.engagementRate > 0 + ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' + : '0%', + icon: Icons.trending_up, + color: UnionFlowColors.gold, + trend: stats != null && stats.engagementRate > 0.9 + ? 'Excellent' + : stats != null && stats.engagementRate > 0.5 + ? 'Bon' + : null, + isTrendUp: (stats?.engagementRate ?? 0) > 0.7, + ), + UnionStatWidget( + label: 'Contribution Totale', + value: _formatAmount(stats?.contributionsAmountOnly ?? stats?.totalContributionAmount ?? 0), + icon: Icons.savings, + color: UnionFlowColors.amber, + ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event_available, + color: UnionFlowColors.terracotta, + ), + ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Activité récente (données backend) + // Activité récente if (dashboardData != null && dashboardData.hasRecentActivity) ...[ const AnimatedFadeIn( delay: Duration(milliseconds: 500), - child: Text( - 'Activité Récente', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), + child: UFSectionHeader('Activité Récente'), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 600), child: Column( children: dashboardData.recentActivities.take(3).map((activity) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - ), - child: Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: activity.type == 'contribution' - ? UnionFlowColors.success.withOpacity(0.2) - : activity.type == 'event' - ? UnionFlowColors.gold.withOpacity(0.2) - : UnionFlowColors.indigo.withOpacity(0.2), - child: Icon( - activity.type == 'contribution' - ? Icons.payment - : activity.type == 'event' - ? Icons.event - : Icons.person_add, - size: 18, - color: activity.type == 'contribution' - ? UnionFlowColors.success - : activity.type == 'event' - ? UnionFlowColors.gold - : UnionFlowColors.indigo, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - activity.description, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Text( - activity.timeAgo, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textTertiary, - ), - ), - ], - ), - ) + DashboardActivityRow( + title: activity.title, + description: activity.description, + timeAgo: activity.timeAgo, + icon: DashboardActivityRow.iconFor(activity.type), + color: DashboardActivityRow.colorFor(activity.type), + ), ).toList(), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), ], - // Bloc Actions rapides unifié (6 boutons regroupés) + // Actions rapides + const AnimatedFadeIn( + delay: Duration(milliseconds: 650), + child: UFSectionHeader('Actions Rapides'), + ), + const SizedBox(height: 8), + AnimatedSlideIn( delay: const Duration(milliseconds: 700), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Actions Rapides', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Cotiser', - icon: Icons.payment, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CotisationsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Épargner', - icon: Icons.savings_outlined, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EpargnePage(), - ), - ); - }, - backgroundColor: UnionFlowColors.gold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Crédit', - icon: Icons.account_balance_wallet, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EpargnePage(), - ), - ); - }, - backgroundColor: UnionFlowColors.amber, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Événements', - icon: Icons.event, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EventsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.terracotta, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Solidarité', - icon: Icons.favorite_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DemandesAidePageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.error, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Profil', - icon: Icons.person_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ProfilePageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.indigo, - ), - ), - ], - ), - ], - ), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, + children: [ + UnionActionButton( + label: 'Cotiser', + icon: Icons.payment, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CotisationsPageWrapper())), + ), + UnionActionButton( + label: 'Épargner', + icon: Icons.savings_outlined, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EpargnePage())), + ), + UnionActionButton( + label: 'Crédit', + icon: Icons.account_balance_wallet, + iconColor: UnionFlowColors.amber, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EpargnePage())), + ), + UnionActionButton( + label: 'Événements', + icon: Icons.event, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), + ), + UnionActionButton( + label: 'Solidarité', + icon: Icons.favorite_outline, + iconColor: UnionFlowColors.error, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DemandesAidePageWrapper())), + ), + UnionActionButton( + label: 'Profil', + icon: Icons.person_outline, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ProfilePageWrapper())), + ), + ], ), ), ], @@ -403,35 +244,14 @@ class ActiveMemberDashboard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: const Text( - 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), + 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'Membre Actif', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('Membre Actif', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], @@ -440,82 +260,9 @@ class ActiveMemberDashboard extends StatelessWidget { ); } - Widget _buildUserHeader(dynamic user) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: UnionFlowColors.warmGradient, - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.gold, width: 3), - ), - boxShadow: UnionFlowColors.goldGlowShadow, - ), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: Text( - user?.initials ?? 'MA', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.fullName ?? 'Membre Actif', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Depuis ${user?.createdAt.year ?? 2024} • Très Actif', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'ACTIF', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.gold, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ); - } - String _formatAmount(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; - } + if (amount >= 1000000) return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; + if (amount >= 1000) return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; return '${amount.toStringAsFixed(0)} FCFA'; } } diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart index 67ca4ea..8875c20 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart @@ -38,150 +38,95 @@ class ConsultantDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // En-tête Consultant - AnimatedFadeIn( - delay: const Duration(milliseconds: 100), - child: _buildUserHeader(), - ), - const SizedBox(height: 24), + // En-tête Consultant + AnimatedFadeIn( + delay: const Duration(milliseconds: 100), + child: UserIdentityCard( + initials: 'CON', + name: 'Consultant Expert', + subtitle: 'Expertise & Analyses Stratégiques', + badgeLabel: 'EXPERT', + gradient: LinearGradient( + colors: [UnionFlowColors.amber, UnionFlowColors.gold], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + accentColor: UnionFlowColors.amber, + ), + ), + const SizedBox(height: 12), - // Stats missions (données backend réelles) + // Stats missions AnimatedSlideIn( delay: const Duration(milliseconds: 200), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, children: [ - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.totalEvents ?? 0}', - icon: Icons.work_outline, - color: UnionFlowColors.amber, - trend: stats?.upcomingEvents != null ? '${stats!.upcomingEvents} à venir' : null, - isTrendUp: true, - ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.totalEvents ?? 0}', + icon: Icons.work_outline, + color: UnionFlowColors.amber, + trend: stats?.upcomingEvents != null ? '${stats!.upcomingEvents} à venir' : null, + isTrendUp: true, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Organisations', - value: '${stats?.totalOrganizations ?? 0}', - icon: Icons.business_outlined, - color: UnionFlowColors.indigo, - ), + UnionStatWidget( + label: 'Organisations', + value: '${stats?.totalOrganizations ?? 0}', + icon: Icons.business_outlined, + color: UnionFlowColors.indigo, + ), + UnionStatWidget( + label: 'Demandes', + value: '${stats?.pendingRequests ?? 0}', + icon: Icons.pending_actions, + color: UnionFlowColors.warning, + ), + UnionStatWidget( + label: 'Membres', + value: '${stats?.totalMembers ?? 0}', + icon: Icons.people_outline, + color: UnionFlowColors.success, ), ], ), ), const SizedBox(height: 12), - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Demandes', - value: '${stats?.pendingRequests ?? 0}', - icon: Icons.pending_actions, - color: UnionFlowColors.warning, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Membres', - value: '${stats?.totalMembers ?? 0}', - icon: Icons.people_outline, - color: UnionFlowColors.success, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Événements à venir (données backend) + // Événements à venir if (dashboardData != null && dashboardData.hasUpcomingEvents) ...[ AnimatedFadeIn( delay: const Duration(milliseconds: 400), - child: const Text( + child: UFSectionHeader( 'Prochains Événements', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), + trailing: '${dashboardData.upcomingEvents.length} programmés', ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 500), child: Column( children: dashboardData.upcomingEvents.take(3).map((event) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: UnionFlowColors.warmGradient, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.calendar_today, - color: Colors.white, - size: 22, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - event.formattedDate, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ) + DashboardEventRow( + title: event.title, + date: event.formattedDate, + ), ).toList(), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), ], - // Répartition organisations par type (données backend) + // Répartition organisations par type if (stats != null && stats.organizationTypeDistribution != null && stats.organizationTypeDistribution!.isNotEmpty) ...[ AnimatedFadeIn( delay: const Duration(milliseconds: 600), @@ -191,90 +136,64 @@ class ConsultantDashboard extends StatelessWidget { sections: _buildOrgTypeSections(stats.organizationTypeDistribution!), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), ], - // Actions consultant + // Mes Outils AnimatedFadeIn( delay: const Duration(milliseconds: 700), - child: const Text( - 'Mes Outils', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + child: const UFSectionHeader('Mes Outils'), ), - ), - ), - const SizedBox(height: 16), + const SizedBox(height: 8), - AnimatedSlideIn( - delay: const Duration(milliseconds: 700), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Audits', - icon: Icons.assessment, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), - backgroundColor: UnionFlowColors.unionGreen, - ), + AnimatedSlideIn( + delay: const Duration(milliseconds: 700), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, + children: [ + UnionActionButton( + label: 'Audits', + icon: Icons.assessment, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), + ), + UnionActionButton( + label: 'Analyses', + icon: Icons.analytics, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), + ), + UnionActionButton( + label: 'Rapports', + icon: Icons.description, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), + ), + UnionActionButton( + label: 'Clients', + icon: Icons.people_outline, + iconColor: UnionFlowColors.amber, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), + ), + UnionActionButton( + label: 'Calendrier', + icon: Icons.calendar_today, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), + ), + UnionActionButton( + label: 'Documents', + icon: Icons.folder_outlined, + iconColor: UnionFlowColors.info, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const HelpSupportPage())), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Analyses', - icon: Icons.analytics, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), - backgroundColor: UnionFlowColors.indigo, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Rapports', - icon: Icons.description, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), - backgroundColor: UnionFlowColors.gold, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - - AnimatedSlideIn( - delay: const Duration(milliseconds: 800), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Clients', - icon: Icons.people_outline, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), - backgroundColor: UnionFlowColors.amber, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Calendrier', - icon: Icons.calendar_today, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), - backgroundColor: UnionFlowColors.terracotta, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Documents', - icon: Icons.folder_outlined, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const HelpSupportPage())), - backgroundColor: UnionFlowColors.info, - ), - ), - ], - ), ), ], ), @@ -303,35 +222,14 @@ class ConsultantDashboard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: const Text( - 'C', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), + child: const Text('C', 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'Consultant', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('Consultant', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], @@ -348,88 +246,8 @@ class ConsultantDashboard extends StatelessWidget { ); } - Widget _buildUserHeader() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [UnionFlowColors.amber, UnionFlowColors.gold], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.amber, width: 3), - ), - boxShadow: [ - BoxShadow( - color: UnionFlowColors.amber.withOpacity(0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: const Text( - 'CON', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Consultant Expert', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Expertise & Analyses Stratégiques', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'EXPERT', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.amber, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ); - } - List _buildOrgTypeSections(Map distribution) { - final colors = [ + const colors = [ UnionFlowColors.unionGreen, UnionFlowColors.gold, UnionFlowColors.indigo, @@ -451,5 +269,3 @@ class ConsultantDashboard extends StatelessWidget { }); } } - - diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart index 266cec1..cee2112 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart @@ -38,175 +38,104 @@ class HRManagerDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // En-tête RH - AnimatedFadeIn( - delay: const Duration(milliseconds: 100), - child: _buildUserHeader(), - ), - const SizedBox(height: 24), + // En-tête RH + AnimatedFadeIn( + delay: const Duration(milliseconds: 100), + child: UserIdentityCard( + initials: 'RH', + name: 'Gestionnaire RH', + subtitle: 'Ressources Humaines & Talents', + badgeLabel: 'RH', + gradient: LinearGradient( + colors: [UnionFlowColors.terracotta, UnionFlowColors.terracottaLight], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + accentColor: UnionFlowColors.terracotta, + ), + ), + const SizedBox(height: 12), - // Stats RH (données backend réelles) + // Stats RH AnimatedSlideIn( delay: const Duration(milliseconds: 200), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, children: [ - Expanded( - child: UnionStatWidget( - label: 'Membres', - value: '${stats?.totalMembers ?? 0}', - icon: Icons.people_outlined, - color: UnionFlowColors.unionGreen, - trend: stats != null && stats.monthlyGrowth > 0 - ? '+${stats.monthlyGrowth.toStringAsFixed(1)}%' - : null, - isTrendUp: (stats?.monthlyGrowth ?? 0) > 0, - ), + UnionStatWidget( + label: 'Membres', + value: '${stats?.totalMembers ?? 0}', + icon: Icons.people_outlined, + color: UnionFlowColors.unionGreen, + trend: stats != null && stats.monthlyGrowth > 0 + ? '+${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, + isTrendUp: (stats?.monthlyGrowth ?? 0) > 0, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Actifs', - value: '${stats?.activeMembers ?? 0}', - icon: Icons.person, - color: UnionFlowColors.success, - trend: stats != null && stats.totalMembers > 0 - ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' - : null, - isTrendUp: true, - ), + UnionStatWidget( + label: 'Actifs', + value: '${stats?.activeMembers ?? 0}', + icon: Icons.person, + color: UnionFlowColors.success, + trend: stats != null && stats.totalMembers > 0 + ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' + : null, + isTrendUp: true, + ), + UnionStatWidget( + label: 'Demandes', + value: '${stats?.pendingRequests ?? 0}', + icon: Icons.pending_actions, + color: UnionFlowColors.amber, + ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event, + color: UnionFlowColors.info, + trend: stats?.totalEvents != null ? '${stats!.totalEvents} total' : null, + isTrendUp: true, ), ], ), ), const SizedBox(height: 12), - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Demandes', - value: '${stats?.pendingRequests ?? 0}', - icon: Icons.pending_actions, - color: UnionFlowColors.amber, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event, - color: UnionFlowColors.info, - trend: stats?.totalEvents != null ? '${stats!.totalEvents} total' : null, - isTrendUp: true, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Activité récente (données backend) + // Activité récente if (dashboardData != null && dashboardData.hasRecentActivity) ...[ AnimatedFadeIn( delay: const Duration(milliseconds: 400), - child: const Text( - 'Activité RH Récente', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), + child: const UFSectionHeader('Activité RH Récente'), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 500), child: Column( children: dashboardData.recentActivities.take(4).map((activity) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - ), - child: Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: activity.type == 'member' - ? UnionFlowColors.success.withOpacity(0.2) - : activity.type == 'contribution' - ? UnionFlowColors.amber.withOpacity(0.2) - : UnionFlowColors.terracotta.withOpacity(0.2), - child: Icon( - activity.type == 'member' - ? Icons.person_add - : activity.type == 'contribution' - ? Icons.payment - : Icons.event, - size: 18, - color: activity.type == 'member' - ? UnionFlowColors.success - : activity.type == 'contribution' - ? UnionFlowColors.amber - : UnionFlowColors.terracotta, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - activity.userName, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Text( - activity.timeAgo, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textTertiary, - ), - ), - ], - ), - ) + DashboardActivityRow( + title: activity.title, + description: activity.description, + timeAgo: activity.timeAgo, + icon: DashboardActivityRow.iconFor(activity.type), + color: DashboardActivityRow.colorFor(activity.type), + ), ).toList(), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), ], - // Répartition membres actifs/inactifs (données backend) - if (stats != null && stats.totalMembers > 0) + // Répartition membres actifs/inactifs + if (stats != null && stats.totalMembers > 0) ...[ AnimatedFadeIn( delay: const Duration(milliseconds: 500), child: UnionPieChart( @@ -226,125 +155,88 @@ class HRManagerDashboard extends StatelessWidget { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), + ], - // Indicateurs RH + // Indicateurs Clés AnimatedFadeIn( delay: const Duration(milliseconds: 600), - child: const Text( - 'Indicateurs Clés', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + child: const UFSectionHeader('Indicateurs Clés'), ), - ), - ), - const SizedBox(height: 16), + const SizedBox(height: 8), - AnimatedSlideIn( - delay: const Duration(milliseconds: 700), - child: Row( - children: [ - Expanded(child: _buildMetric('Turnover', '5%', UnionFlowColors.success)), - const SizedBox(width: 12), - Expanded(child: _buildMetric('Absentéisme', '2%', UnionFlowColors.warning)), - ], - ), - ), - const SizedBox(height: 12), - - AnimatedSlideIn( - delay: const Duration(milliseconds: 800), - child: Row( - children: [ - Expanded(child: _buildMetric('Satisfaction', '87%', UnionFlowColors.gold)), - const SizedBox(width: 12), - Expanded(child: _buildMetric('Formation', '45h', UnionFlowColors.indigo)), - ], - ), - ), - const SizedBox(height: 24), - - // Actions RH - AnimatedFadeIn( - delay: const Duration(milliseconds: 900), - child: const Text( - 'Gestion RH', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + AnimatedSlideIn( + delay: const Duration(milliseconds: 700), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, + children: [ + UnionStatWidget(label: 'Turnover', value: '5%', icon: Icons.swap_horiz, color: UnionFlowColors.success), + UnionStatWidget(label: 'Absentéisme', value: '2%', icon: Icons.event_busy, color: UnionFlowColors.warning), + UnionStatWidget(label: 'Satisfaction', value: '87%', icon: Icons.sentiment_satisfied, color: UnionFlowColors.gold), + UnionStatWidget(label: 'Formation', value: '45h', icon: Icons.school, color: UnionFlowColors.indigo), + ], + ), ), - ), - ), - const SizedBox(height: 16), + const SizedBox(height: 12), - AnimatedSlideIn( - delay: const Duration(milliseconds: 1000), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Employés', - icon: Icons.people, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), - backgroundColor: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Congés', - icon: Icons.event_available, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), - backgroundColor: UnionFlowColors.amber, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Paie', - icon: Icons.payments, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContributionsPageWrapper())), - backgroundColor: UnionFlowColors.gold, - ), - ), - ], - ), - ), - const SizedBox(height: 12), + // Actions RH + AnimatedFadeIn( + delay: const Duration(milliseconds: 900), + child: const UFSectionHeader('Gestion RH'), + ), + const SizedBox(height: 8), - AnimatedSlideIn( - delay: const Duration(milliseconds: 1100), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Recrutement', - icon: Icons.person_add, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), - backgroundColor: UnionFlowColors.info, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Formation', - icon: Icons.school, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), - backgroundColor: UnionFlowColors.indigo, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Rapports', - icon: Icons.analytics, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), - backgroundColor: UnionFlowColors.terracotta, - ), - ), + AnimatedSlideIn( + delay: const Duration(milliseconds: 1000), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, + children: [ + UnionActionButton( + label: 'Employés', + icon: Icons.people, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), + ), + UnionActionButton( + label: 'Congés', + icon: Icons.event_available, + iconColor: UnionFlowColors.amber, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), + ), + UnionActionButton( + label: 'Paie', + icon: Icons.payments, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContributionsPageWrapper())), + ), + UnionActionButton( + label: 'Recrutement', + icon: Icons.person_add, + iconColor: UnionFlowColors.info, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), + ), + UnionActionButton( + label: 'Formation', + icon: Icons.school, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), + ), + UnionActionButton( + label: 'Rapports', + icon: Icons.analytics, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), + ), ], ), ), @@ -375,35 +267,14 @@ class HRManagerDashboard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: const Text( - 'H', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), + child: const Text('H', 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'RH Manager', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('RH Manager', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], @@ -428,115 +299,4 @@ class HRManagerDashboard extends StatelessWidget { ], ); } - - Widget _buildUserHeader() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [UnionFlowColors.terracotta, UnionFlowColors.terracottaLight], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.terracotta, width: 3), - ), - boxShadow: [ - BoxShadow( - color: UnionFlowColors.terracotta.withOpacity(0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: const Text( - 'RH', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Gestionnaire RH', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Ressources Humaines & Talents', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'RH', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.terracotta, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ); - } - - Widget _buildMetric(String label, String value, Color color) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, - ), - child: Column( - children: [ - Text( - value, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: color, - ), - ), - const SizedBox(height: 4), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ); - } } diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/moderator_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/moderator_dashboard.dart index 69b883d..e3707f1 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/moderator_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/moderator_dashboard.dart @@ -17,15 +17,6 @@ import '../../widgets/dashboard_drawer.dart'; class ModeratorDashboard extends StatelessWidget { const ModeratorDashboard({super.key}); - String _formatAmount(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(amount % 1000000 == 0 ? 0 : 1)}M FCFA'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(amount % 1000 == 0 ? 0 : 1)}K FCFA'; - } - return '${amount.toStringAsFixed(0)} FCFA'; - } - @override Widget build(BuildContext context) { return Scaffold( @@ -54,18 +45,25 @@ class ModeratorDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête Modérateur AnimatedFadeIn( delay: const Duration(milliseconds: 100), - child: _buildUserHeader(user), + child: UserIdentityCard( + initials: user?.initials ?? 'SM', + name: user?.fullName ?? 'Secrétaire', + subtitle: 'Depuis ${user?.createdAt?.year ?? DateTime.now().year} · Très Actif', + badgeLabel: 'ACTIF', + gradient: UnionFlowColors.warmGradient, + accentColor: UnionFlowColors.gold, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Balance principale ou Vue Unifiée (Compte Adhérent) + // Balance / Vue Unifiée AnimatedSlideIn( delay: const Duration(milliseconds: 200), child: dashboardData?.monCompte != null @@ -81,290 +79,180 @@ class ModeratorDashboard extends StatelessWidget { label: 'Mon Solde Total', amount: _formatAmount(stats?.totalContributionAmount ?? 0), trend: stats != null && stats.monthlyGrowth != 0 - ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}% ce mois' - : 'Aucune variation', + ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Bloc KPI unifié (4 stats regroupées) + // KPIs membre AnimatedFadeIn( delay: const Duration(milliseconds: 250), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Cotisations', - value: '${stats?.totalContributions ?? 0}', - icon: Icons.check_circle, - color: (stats?.totalContributions ?? 0) > 0 - ? UnionFlowColors.success - : UnionFlowColors.textTertiary, - trend: stats != null && stats.totalContributions > 0 && stats.engagementRate > 0 - ? (stats.engagementRate >= 1.0 - ? 'Tout payé' - : '${(stats.engagementRate * 100).toStringAsFixed(0)}% payé') - : null, - isTrendUp: (stats?.engagementRate ?? 0) >= 1.0, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Engagement', - value: stats != null && stats.engagementRate > 0 - ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' - : stats != null && stats.totalContributions > 0 ? '—' : '0%', - icon: Icons.trending_up, - color: UnionFlowColors.gold, - trend: stats != null && stats.engagementRate > 0.9 - ? 'Excellent' - : stats != null && stats.engagementRate > 0.5 ? 'Bon' : null, - isTrendUp: (stats?.engagementRate ?? 0) > 0.7, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Contribution Totale', - value: _formatAmount(stats?.contributionsAmountOnly ?? stats?.totalContributionAmount ?? 0), - icon: Icons.savings, - color: UnionFlowColors.amber, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event_available, - color: UnionFlowColors.terracotta, - ), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 24), - - // Bloc Actions rapides unifié (6 boutons regroupés) - AnimatedSlideIn( - delay: const Duration(milliseconds: 300), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Actions Rapides', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Cotiser', - icon: Icons.payment, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CotisationsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Épargner', - icon: Icons.savings_outlined, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EpargnePage(), - ), - ); - }, - backgroundColor: UnionFlowColors.gold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Crédit', - icon: Icons.account_balance_wallet, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EpargnePage(), - ), - ); - }, - backgroundColor: UnionFlowColors.amber, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Événements', - icon: Icons.event, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EventsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.terracotta, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Solidarité', - icon: Icons.favorite_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DemandesAidePageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.error, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Profil', - icon: Icons.person_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ProfilePageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.indigo, - ), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 32), - - // ——— Administration / Modération (tout en bas, après les actions membre) ——— - AnimatedFadeIn( - delay: const Duration(milliseconds: 600), - child: const Text( - 'Espace Modérateur', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - ), - const SizedBox(height: 16), - - // Stats de modération (données backend réelles) - AnimatedSlideIn( - delay: const Duration(milliseconds: 600), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, children: [ - Expanded( - child: UnionStatWidget( - label: 'En attente', - value: '${stats?.pendingRequests ?? 0}', - icon: Icons.pending_actions, - color: UnionFlowColors.warning, - trend: stats != null && stats.pendingRequests > 0 ? 'Action requise' : null, - isTrendUp: false, - ), + UnionStatWidget( + label: 'Cotisations', + value: '${stats?.totalContributions ?? 0}', + icon: Icons.check_circle, + color: (stats?.totalContributions ?? 0) > 0 + ? UnionFlowColors.success + : UnionFlowColors.textTertiary, + trend: stats != null && stats.totalContributions > 0 && stats.engagementRate > 0 + ? (stats.engagementRate >= 1.0 + ? 'Tout payé' + : '${(stats.engagementRate * 100).toStringAsFixed(0)}% payé') + : null, + isTrendUp: (stats?.engagementRate ?? 0) >= 1.0, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Membres Actifs', - value: '${stats?.activeMembers ?? 0}', - icon: Icons.check_circle_outline, - color: UnionFlowColors.success, - trend: stats != null && stats.totalMembers > 0 - ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' - : null, - isTrendUp: true, - ), + UnionStatWidget( + label: 'Engagement', + value: stats != null && stats.engagementRate > 0 + ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' + : '0%', + icon: Icons.trending_up, + color: UnionFlowColors.gold, + trend: stats != null && stats.engagementRate > 0.9 + ? 'Excellent' + : stats != null && stats.engagementRate > 0.5 ? 'Bon' : null, + isTrendUp: (stats?.engagementRate ?? 0) > 0.7, + ), + UnionStatWidget( + label: 'Contribution Totale', + value: _formatAmount(stats?.contributionsAmountOnly ?? stats?.totalContributionAmount ?? 0), + icon: Icons.savings, + color: UnionFlowColors.amber, + ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event_available, + color: UnionFlowColors.terracotta, ), ], ), ), const SizedBox(height: 12), - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), - child: Row( + // Actions rapides membre + const AnimatedFadeIn( + delay: Duration(milliseconds: 300), + child: UFSectionHeader('Actions Rapides'), + ), + const SizedBox(height: 8), + + AnimatedSlideIn( + delay: const Duration(milliseconds: 350), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, children: [ - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event_outlined, - color: UnionFlowColors.gold, - ), + UnionActionButton( + label: 'Cotiser', + icon: Icons.payment, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CotisationsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Membres Total', - value: '${stats?.totalMembers ?? 0}', - icon: Icons.people_outline, - color: UnionFlowColors.unionGreen, - ), + UnionActionButton( + label: 'Épargner', + icon: Icons.savings_outlined, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EpargnePage())), + ), + UnionActionButton( + label: 'Crédit', + icon: Icons.account_balance_wallet, + iconColor: UnionFlowColors.amber, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EpargnePage())), + ), + UnionActionButton( + label: 'Événements', + icon: Icons.event, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), + ), + UnionActionButton( + label: 'Solidarité', + icon: Icons.favorite_outline, + iconColor: UnionFlowColors.error, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DemandesAidePageWrapper())), + ), + UnionActionButton( + label: 'Profil', + icon: Icons.person_outline, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ProfilePageWrapper())), ), ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), - // Activité des membres (données backend réelles) - if (stats != null && stats.totalMembers > 0) + // ——— Espace Modérateur ——— + const AnimatedFadeIn( + delay: Duration(milliseconds: 600), + child: UFSectionHeader('Espace Modérateur'), + ), + const SizedBox(height: 8), + + // Stats de modération + AnimatedSlideIn( + delay: const Duration(milliseconds: 600), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, + children: [ + UnionStatWidget( + label: 'En attente', + value: '${stats?.pendingRequests ?? 0}', + icon: Icons.pending_actions, + color: UnionFlowColors.warning, + trend: stats != null && stats.pendingRequests > 0 ? 'Action requise' : null, + isTrendUp: false, + ), + UnionStatWidget( + label: 'Membres Actifs', + value: '${stats?.activeMembers ?? 0}', + icon: Icons.check_circle_outline, + color: UnionFlowColors.success, + trend: stats != null && stats.totalMembers > 0 + ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' + : null, + isTrendUp: true, + ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event_outlined, + color: UnionFlowColors.gold, + ), + UnionStatWidget( + label: 'Membres Total', + value: '${stats?.totalMembers ?? 0}', + icon: Icons.people_outline, + color: UnionFlowColors.unionGreen, + ), + ], + ), + ), + const SizedBox(height: 12), + + // Activité des membres + if (stats != null && stats.totalMembers > 0) ...[ AnimatedSlideIn( delay: const Duration(milliseconds: 400), child: UnionPieChart( @@ -384,157 +272,79 @@ class ModeratorDashboard extends StatelessWidget { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), + ], - // Demandes en attente (données backend) + // Activité récente à modérer if (dashboardData != null && dashboardData.hasRecentActivity) ...[ AnimatedFadeIn( delay: const Duration(milliseconds: 500), - child: const Text( - 'Activité Récente à Modérer', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), + child: const UFSectionHeader('Activité Récente à Modérer'), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 600), child: Column( children: dashboardData.recentActivities.take(4).map((activity) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: UnionFlowColors.indigo.withOpacity(0.2), - child: Icon( - activity.type == 'member' ? Icons.person_add : - activity.type == 'event' ? Icons.event : - Icons.info_outline, - size: 20, - color: UnionFlowColors.indigo, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - activity.description, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Text( - activity.timeAgo, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textTertiary, - ), - ), - ], - ), - ) + DashboardActivityRow( + title: activity.title, + description: activity.description, + timeAgo: activity.timeAgo, + icon: DashboardActivityRow.iconFor(activity.type), + color: DashboardActivityRow.colorFor(activity.type), + ), ).toList(), ), ), + const SizedBox(height: 12), ], - const SizedBox(height: 24), // Actions de modération AnimatedSlideIn( delay: const Duration(milliseconds: 700), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, children: [ - Expanded( - child: UnionActionButton( - label: 'Approuver', - icon: Icons.check_circle, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), - backgroundColor: UnionFlowColors.success, - ), + UnionActionButton( + label: 'Approuver', + icon: Icons.check_circle, + iconColor: UnionFlowColors.success, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Vérifier', - icon: Icons.visibility, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), - backgroundColor: UnionFlowColors.info, - ), + UnionActionButton( + label: 'Vérifier', + icon: Icons.visibility, + iconColor: UnionFlowColors.info, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Signaler', - icon: Icons.flag, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const HelpSupportPage())), - backgroundColor: UnionFlowColors.error, - ), + UnionActionButton( + label: 'Signaler', + icon: Icons.flag, + iconColor: UnionFlowColors.error, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const HelpSupportPage())), ), - ], - ), - ), - const SizedBox(height: 12), - - AnimatedSlideIn( - delay: const Duration(milliseconds: 800), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Membres', - icon: Icons.people, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), - backgroundColor: UnionFlowColors.unionGreen, - ), + UnionActionButton( + label: 'Membres', + icon: Icons.people, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Contenus', - icon: Icons.article, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), - backgroundColor: UnionFlowColors.gold, - ), + UnionActionButton( + label: 'Contenus', + icon: Icons.article, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Historique', - icon: Icons.history, - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContributionsPageWrapper())), - backgroundColor: UnionFlowColors.indigo, - ), + UnionActionButton( + label: 'Historique', + icon: Icons.history, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContributionsPageWrapper())), ), ], ), @@ -564,35 +374,14 @@ class ModeratorDashboard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: const Text( - 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), + 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'Modérateur', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('Modérateur', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], @@ -601,74 +390,12 @@ class ModeratorDashboard extends StatelessWidget { ); } - Widget _buildUserHeader(dynamic user) { - final year = user?.createdAt?.year ?? DateTime.now().year; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: UnionFlowColors.warmGradient, - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.gold, width: 3), - ), - boxShadow: UnionFlowColors.goldGlowShadow, - ), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: Text( - user?.initials ?? 'SM', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.fullName ?? 'Secrétaire', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Depuis $year • Très Actif', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'ACTIF', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.gold, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ); + String _formatAmount(double amount) { + if (amount >= 1000000) { + return '${(amount / 1000000).toStringAsFixed(amount % 1000000 == 0 ? 0 : 1)}M FCFA'; + } else if (amount >= 1000) { + return '${(amount / 1000).toStringAsFixed(amount % 1000 == 0 ? 0 : 1)}K FCFA'; + } + return '${amount.toStringAsFixed(0)} FCFA'; } } diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart index 14eeb67..53b02ea 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart @@ -10,6 +10,7 @@ import '../../../../events/presentation/pages/events_page_wrapper.dart'; import '../../../../contributions/presentation/pages/contributions_page_wrapper.dart'; import '../../../../reports/presentation/pages/reports_page_wrapper.dart'; import '../../../../notifications/presentation/pages/notifications_page_wrapper.dart'; +import '../../../../adhesions/presentation/pages/adhesions_page_wrapper.dart'; import '../../widgets/dashboard_drawer.dart'; import '../../../data/datasources/dashboard_remote_datasource.dart'; import '../../../data/services/dashboard_export_service.dart'; @@ -25,12 +26,8 @@ class OrgAdminDashboard extends StatelessWidget { backgroundColor: UnionFlowColors.background, appBar: _buildAppBar(context), drawer: DashboardDrawer( - onNavigate: (route) { - Navigator.of(context).pushNamed(route); - }, - onLogout: () { - context.read().add(const AuthLogoutRequested()); - }, + onNavigate: (route) => Navigator.of(context).pushNamed(route), + onLogout: () => context.read().add(const AuthLogoutRequested()), ), body: AfricanPatternBackground( child: BlocBuilder( @@ -54,277 +51,98 @@ class OrgAdminDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête Admin AnimatedFadeIn( delay: const Duration(milliseconds: 100), - child: _buildUserHeader(user, orgContext), + child: UserIdentityCard( + initials: user?.initials ?? 'AD', + name: user?.fullName ?? 'Administrateur', + subtitle: orgContext?.organizationName ?? 'Organisation', + badgeLabel: 'ADMIN', + gradient: UnionFlowColors.goldGradient, + accentColor: UnionFlowColors.gold, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Balance organisation (données backend réelles) + // Balance organisation AnimatedSlideIn( delay: const Duration(milliseconds: 200), child: UnionBalanceCard( label: 'Caisse de l\'Organisation', amount: _formatAmount(stats?.totalContributionAmount ?? 0), trend: stats != null && stats.monthlyGrowth != 0 - ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}% ce mois' - : 'Aucune variation', + ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Stats organisation (données backend réelles) + // Stats organisation — cellules très plates AnimatedFadeIn( delay: const Duration(milliseconds: 300), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 3.0, children: [ - Expanded( - child: UnionStatWidget( - label: 'Membres', - value: '${stats?.totalMembers ?? 0}', - icon: Icons.people_outlined, - color: UnionFlowColors.unionGreen, - trend: stats != null && stats.monthlyGrowth > 0 - ? '+${stats.monthlyGrowth.toStringAsFixed(1)}%' - : null, - isTrendUp: (stats?.monthlyGrowth ?? 0) > 0, - ), + UnionStatWidget( + compact: true, + label: 'Membres', + value: '${stats?.totalMembers ?? 0}', + icon: Icons.people_outlined, + color: UnionFlowColors.unionGreen, + trend: stats != null && stats.monthlyGrowth > 0 + ? '+${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, + isTrendUp: (stats?.monthlyGrowth ?? 0) > 0, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Actifs', - value: '${stats?.activeMembers ?? 0}', - icon: Icons.check_circle_outline, - color: UnionFlowColors.success, - trend: stats != null && stats.totalMembers > 0 - ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' - : null, - isTrendUp: true, - ), + UnionStatWidget( + compact: true, + label: 'Actifs', + value: '${stats?.activeMembers ?? 0}', + icon: Icons.check_circle_outline, + color: UnionFlowColors.success, + trend: stats != null && stats.totalMembers > 0 + ? '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%' + : null, + isTrendUp: true, + ), + UnionStatWidget( + compact: true, + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event_outlined, + color: UnionFlowColors.gold, + trend: stats?.totalEvents != null ? '${stats!.totalEvents} total' : null, + isTrendUp: true, + ), + UnionStatWidget( + compact: true, + label: 'Cotisations', + value: '${stats?.totalContributions ?? 0}', + icon: Icons.trending_up, + color: UnionFlowColors.amber, + trend: _formatAmount(stats?.totalContributionAmount ?? 0), + isTrendUp: true, ), ], ), ), const SizedBox(height: 12), - AnimatedFadeIn( - delay: const Duration(milliseconds: 400), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event_outlined, - color: UnionFlowColors.gold, - trend: stats?.totalEvents != null ? '${stats!.totalEvents} total' : null, - isTrendUp: true, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Cotisations', - value: '${stats?.totalContributions ?? 0}', - icon: Icons.trending_up, - color: UnionFlowColors.amber, - trend: _formatAmount(stats?.totalContributionAmount ?? 0), - isTrendUp: true, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Événements à venir (données backend) - if (dashboardData != null && dashboardData.hasUpcomingEvents) ...[ - AnimatedFadeIn( - delay: const Duration(milliseconds: 500), - child: const Text( - 'Événements à Venir', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - ), - const SizedBox(height: 16), + // Répartition actifs / inactifs + if (stats != null && stats.totalMembers > 0) ...[ AnimatedSlideIn( - delay: const Duration(milliseconds: 600), - child: Column( - children: dashboardData.upcomingEvents.take(3).map((event) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - gradient: UnionFlowColors.warmGradient, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.event, - color: Colors.white, - size: 26, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - event.formattedDate, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - if (event.hasParticipantInfo) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: UnionFlowColors.gold.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${event.currentParticipants}/${event.maxParticipants}', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: UnionFlowColors.gold, - ), - ), - ), - ], - ), - ) - ).toList(), - ), - ), - const SizedBox(height: 24), - ], - - // Activité récente (données backend) - if (dashboardData != null && dashboardData.hasRecentActivity) ...[ - AnimatedFadeIn( - delay: const Duration(milliseconds: 520), - child: const Text( - 'Activité Récente', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - ), - const SizedBox(height: 16), - AnimatedSlideIn( - delay: const Duration(milliseconds: 580), - child: Column( - children: dashboardData.recentActivities.take(4).map((activity) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: UnionFlowColors.gold.withOpacity(0.2), - child: Icon( - activity.type == 'member' ? Icons.person_add : - activity.type == 'event' ? Icons.event : - Icons.info_outline, - size: 20, - color: UnionFlowColors.gold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - activity.description, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Text( - activity.timeAgo, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textTertiary, - ), - ), - ], - ), - ), - ).toList(), - ), - ), - const SizedBox(height: 24), - ], - - // Répartition actifs / inactifs (données backend) - if (stats != null && stats.totalMembers > 0) - AnimatedSlideIn( - delay: const Duration(milliseconds: 700), + delay: const Duration(milliseconds: 400), child: UnionPieChart( title: 'Activité des Membres', subtitle: '${stats.totalMembers} membres', @@ -346,100 +164,112 @@ class OrgAdminDashboard extends StatelessWidget { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), + ], - // Gestion - AnimatedFadeIn( - delay: const Duration(milliseconds: 800), - child: const Text( - 'Gestion de l\'Organisation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, + // Événements à venir + if (dashboardData != null && dashboardData.hasUpcomingEvents) ...[ + AnimatedFadeIn( + delay: const Duration(milliseconds: 500), + child: UFSectionHeader( + 'Événements à Venir', + trailing: '${dashboardData.upcomingEvents.length} programmés', ), ), + const SizedBox(height: 8), + AnimatedSlideIn( + delay: const Duration(milliseconds: 600), + child: Column( + children: dashboardData.upcomingEvents.take(3).map((event) => + DashboardEventRow( + title: event.title, + date: event.formattedDate, + participants: event.hasParticipantInfo + ? '${event.currentParticipants}/${event.maxParticipants}' + : null, + ), + ).toList(), + ), + ), + const SizedBox(height: 12), + ], + + // Activité récente + if (dashboardData != null && dashboardData.hasRecentActivity) ...[ + AnimatedFadeIn( + delay: const Duration(milliseconds: 520), + child: const UFSectionHeader('Activité Récente'), + ), + const SizedBox(height: 8), + AnimatedSlideIn( + delay: const Duration(milliseconds: 580), + child: Column( + children: dashboardData.recentActivities.take(4).map((activity) => + DashboardActivityRow( + title: activity.title, + description: activity.description, + timeAgo: activity.timeAgo, + icon: DashboardActivityRow.iconFor(activity.type), + color: DashboardActivityRow.colorFor(activity.type), + ), + ).toList(), + ), + ), + const SizedBox(height: 12), + ], + + // Gestion de l'Organisation + AnimatedFadeIn( + delay: const Duration(milliseconds: 800), + child: const UFSectionHeader('Gestion de l\'Organisation'), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 900), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, children: [ - Expanded( - child: UnionActionButton( - label: 'Membres', - icon: Icons.people, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const MembersPageWrapper(), - ), - ), - backgroundColor: UnionFlowColors.unionGreen, - ), + UnionActionButton( + label: 'Membres', + icon: Icons.people, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MembersPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Finance', - icon: Icons.account_balance, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CotisationsPageWrapper(), - ), - ), - backgroundColor: UnionFlowColors.gold, - ), + UnionActionButton( + label: 'Finance', + icon: Icons.account_balance, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CotisationsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Événements', - icon: Icons.event, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EventsPageWrapper(), - ), - ), - backgroundColor: UnionFlowColors.terracotta, - ), + UnionActionButton( + label: 'Événements', + icon: Icons.event, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EventsPageWrapper())), ), - ], - ), - ), - const SizedBox(height: 12), - - // Paramètres Système réservé au super admin (pas affiché pour org admin) - AnimatedSlideIn( - delay: const Duration(milliseconds: 1000), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Rapports', - icon: Icons.description, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReportsPageWrapper(), - ), - ), - backgroundColor: UnionFlowColors.info, - ), + UnionActionButton( + label: 'Rapports', + icon: Icons.description, + iconColor: UnionFlowColors.info, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Historique', - icon: Icons.history, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReportsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.amber, - ), + UnionActionButton( + label: 'Adhésions', + icon: Icons.person_add, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdhesionsPageWrapper())), + ), + UnionActionButton( + label: 'Historique', + icon: Icons.history, + iconColor: UnionFlowColors.amber, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ReportsPageWrapper())), ), ], ), @@ -471,42 +301,22 @@ class OrgAdminDashboard extends StatelessWidget { alignment: Alignment.center, child: const Text( 'A', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), + 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'Admin Organisation', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('Admin Organisation', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], ), iconTheme: const IconThemeData(color: UnionFlowColors.textPrimary), actions: [ - UnionExportButton( - onExport: (_) => _handleExport(context), - ), + UnionExportButton(onExport: (_) => _handleExport(context)), const SizedBox(width: 8), UnionNotificationBadge( count: 0, @@ -514,9 +324,7 @@ class OrgAdminDashboard extends StatelessWidget { icon: const Icon(Icons.notifications_outlined), color: UnionFlowColors.textPrimary, onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const NotificationsPageWrapper(), - ), + MaterialPageRoute(builder: (_) => const NotificationsPageWrapper()), ), ), ), @@ -525,86 +333,9 @@ class OrgAdminDashboard extends StatelessWidget { ); } - Widget _buildUserHeader(dynamic user, dynamic orgContext) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: UnionFlowColors.goldGradient, - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.gold, width: 3), - ), - boxShadow: UnionFlowColors.goldGlowShadow, - ), - child: Column( - children: [ - Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: Text( - user?.initials ?? 'AD', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.fullName ?? 'Administrateur', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - orgContext?.organizationName ?? 'Organisation', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'ADMIN', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.gold, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ], - ), - ); - } - String _formatAmount(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; - } + if (amount >= 1000000) return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; + if (amount >= 1000) return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; return '${amount.toStringAsFixed(0)} FCFA'; } @@ -629,9 +360,6 @@ class OrgAdminDashboard extends StatelessWidget { return; } final orgCtx = user.organizationContexts.first; - final orgId = orgCtx.organizationId; - final orgName = orgCtx.organizationName; - final userId = user.id; showDialog( context: context, barrierDismissible: false, @@ -639,10 +367,10 @@ class OrgAdminDashboard extends StatelessWidget { ); try { final dataSource = sl(); - final data = await dataSource.getDashboardData(orgId, userId); + final data = await dataSource.getDashboardData(orgCtx.organizationId, user.id); final path = await DashboardExportService().exportDashboardReport( dashboardData: data, - organizationName: orgName, + organizationName: orgCtx.organizationName, reportTitle: 'Rapport dashboard - ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year}', ); if (context.mounted) { diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/simple_member_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/simple_member_dashboard.dart index f0de7e1..cb582cb 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/simple_member_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/simple_member_dashboard.dart @@ -19,12 +19,8 @@ class SimpleMemberDashboard extends StatelessWidget { backgroundColor: UnionFlowColors.background, appBar: _buildAppBar(), drawer: DashboardDrawer( - onNavigate: (route) { - Navigator.of(context).pushNamed(route); - }, - onLogout: () { - context.read().add(const AuthLogoutRequested()); - }, + onNavigate: (route) => Navigator.of(context).pushNamed(route), + onLogout: () => context.read().add(const AuthLogoutRequested()), ), body: AfricanPatternBackground( child: BlocBuilder( @@ -45,16 +41,24 @@ class SimpleMemberDashboard extends StatelessWidget { final stats = dashboardData?.stats; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête avec badge de rôle AnimatedFadeIn( delay: const Duration(milliseconds: 100), - child: _buildUserHeader(user), + child: UserIdentityCard( + initials: user?.initials ?? 'M', + name: user?.fullName ?? 'Membre', + subtitle: 'Membre depuis ${user?.createdAt.year ?? 2024}', + badgeLabel: 'MEMBRE', + gradient: UnionFlowColors.subtleGradient, + accentColor: UnionFlowColors.unionGreen, + lightBackground: true, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Solde personnel AnimatedSlideIn( @@ -63,232 +67,114 @@ class SimpleMemberDashboard extends StatelessWidget { label: 'Mon Solde', amount: _formatAmount(stats?.totalContributionAmount ?? 0), trend: stats != null && stats.monthlyGrowth != 0 - ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}% ce mois' - : 'Aucune variation', + ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), - // Ma situation + // Ma Situation AnimatedFadeIn( delay: const Duration(milliseconds: 300), - child: const Text( - 'Ma Situation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), + child: const UFSectionHeader('Ma Situation'), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 400), - child: Row( + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, children: [ - Expanded( - child: UnionStatWidget( - label: 'Cotisations', - value: stats != null && stats.totalContributions > 0 ? 'À jour' : 'En retard', - icon: Icons.check_circle_outline, - color: stats != null && stats.totalContributions > 0 - ? UnionFlowColors.success - : UnionFlowColors.warning, - ), + UnionStatWidget( + label: 'Cotisations', + value: stats != null && stats.totalContributions > 0 ? 'À jour' : 'En retard', + icon: Icons.check_circle_outline, + color: stats != null && stats.totalContributions > 0 + ? UnionFlowColors.success + : UnionFlowColors.warning, ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Événements', - value: '${stats?.upcomingEvents ?? 0}', - icon: Icons.event_outlined, - color: UnionFlowColors.gold, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Actions rapides - AnimatedFadeIn( - delay: const Duration(milliseconds: 500), - child: const Text( - 'Actions Rapides', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - ), - const SizedBox(height: 16), - - AnimatedSlideIn( - delay: const Duration(milliseconds: 600), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Cotiser', - icon: Icons.payment, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CotisationsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Épargner', - icon: Icons.savings_outlined, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const EpargnePage(), - ), - ); - }, - backgroundColor: UnionFlowColors.gold, - ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.upcomingEvents ?? 0}', + icon: Icons.event_outlined, + color: UnionFlowColors.gold, ), ], ), ), const SizedBox(height: 12), + // Actions rapides + AnimatedFadeIn( + delay: const Duration(milliseconds: 500), + child: const UFSectionHeader('Actions Rapides'), + ), + const SizedBox(height: 8), + AnimatedSlideIn( - delay: const Duration(milliseconds: 700), - child: Row( + delay: const Duration(milliseconds: 600), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, children: [ - Expanded( - child: UnionActionButton( - label: 'Mes Infos', - icon: Icons.person_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ProfilePageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.indigo, - ), + UnionActionButton( + label: 'Cotiser', + icon: Icons.payment, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CotisationsPageWrapper())), ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Support', - icon: Icons.help_outline, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const HelpSupportPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.terracotta, - ), + UnionActionButton( + label: 'Épargner', + icon: Icons.savings_outlined, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const EpargnePage())), + ), + UnionActionButton( + label: 'Mes Infos', + icon: Icons.person_outline, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ProfilePageWrapper())), + ), + UnionActionButton( + label: 'Support', + icon: Icons.help_outline, + iconColor: UnionFlowColors.terracotta, + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const HelpSupportPage())), ), ], ), ), - const SizedBox(height: 24), - // Événements à venir (données backend) + // Événements à venir if (dashboardData != null && dashboardData.hasUpcomingEvents) ...[ + const SizedBox(height: 12), AnimatedFadeIn( delay: const Duration(milliseconds: 800), - child: const Text( + child: UFSectionHeader( 'Événements à Venir', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), + trailing: '${dashboardData.upcomingEvents.length} programmés', ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedSlideIn( delay: const Duration(milliseconds: 900), child: Column( children: dashboardData.upcomingEvents.take(2).map((event) => - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UnionFlowColors.border, width: 1), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: UnionFlowColors.gold.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.event, - color: UnionFlowColors.gold, - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - event.formattedDate, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - if (event.daysUntilEventInt <= 7) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: UnionFlowColors.warning.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${event.daysUntilEventInt}j', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: UnionFlowColors.warning, - ), - ), - ), - ], - ), - ) + DashboardEventRow( + title: event.title, + date: event.formattedDate, + daysUntil: event.daysUntilEventInt <= 7 ? '${event.daysUntilEventInt}j' : null, + ), ).toList(), ), ), @@ -318,35 +204,14 @@ class SimpleMemberDashboard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: const Text( - 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), + 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: UnionFlowColors.textPrimary, - ), - ), - Text( - 'Membre Simple', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: UnionFlowColors.textSecondary, - ), - ), + Text('UnionFlow', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), + Text('Membre Simple', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w400, color: UnionFlowColors.textSecondary)), ], ), ], @@ -355,82 +220,9 @@ class SimpleMemberDashboard extends StatelessWidget { ); } - Widget _buildUserHeader(dynamic user) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: UnionFlowColors.subtleGradient, - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.unionGreen, width: 3), - ), - boxShadow: UnionFlowColors.softShadow, - ), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: UnionFlowColors.unionGreen.withOpacity(0.2), - child: Text( - user?.initials ?? 'M', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: UnionFlowColors.unionGreen, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.fullName ?? 'Membre', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - 'Membre depuis ${user?.createdAt.year ?? 2024}', - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: UnionFlowColors.unionGreen, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'MEMBRE', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ); - } - String _formatAmount(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; - } + if (amount >= 1000000) return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; + if (amount >= 1000) return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; return '${amount.toStringAsFixed(0)} FCFA'; } } diff --git a/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart b/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart index 213e43a..deef259 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart @@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter/material.dart'; import '../../../../../shared/design_system/unionflow_design_v2.dart'; import '../../bloc/dashboard_bloc.dart'; +import '../../../domain/entities/dashboard_entity.dart'; import '../../../../authentication/presentation/bloc/auth_bloc.dart'; import '../../../../organizations/presentation/pages/organizations_page_wrapper.dart'; import '../../../../admin/presentation/pages/user_management_page.dart'; @@ -10,7 +11,7 @@ import '../../../../backup/presentation/pages/backup_page.dart'; import '../../../../help/presentation/pages/help_support_page.dart'; import '../../widgets/dashboard_drawer.dart'; -/// Dashboard Super Admin - Design UnionFlow Contrôle Système +/// Dashboard Super Admin — design fintech compact class SuperAdminDashboard extends StatelessWidget { const SuperAdminDashboard({super.key}); @@ -18,448 +19,271 @@ class SuperAdminDashboard extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: UnionFlowColors.background, - appBar: _buildAppBar(), + appBar: _buildAppBar(context), drawer: DashboardDrawer( - onNavigate: (route) { - Navigator.of(context).pushNamed(route); - }, - onLogout: () { - context.read().add(const AuthLogoutRequested()); - }, + onNavigate: (route) => Navigator.of(context).pushNamed(route), + onLogout: () => context.read().add(const AuthLogoutRequested()), ), - body: AfricanPatternBackground( - child: BlocBuilder( - builder: (context, authState) { - final user = (authState is AuthAuthenticated) ? authState.user : null; + body: BlocBuilder( + builder: (context, authState) { + final user = (authState is AuthAuthenticated) ? authState.user : null; + return BlocBuilder( + builder: (context, dashboardState) { + if (dashboardState is DashboardLoading) { + return const Center( + child: CircularProgressIndicator( + color: UnionFlowColors.error, + strokeWidth: 2, + ), + ); + } + final dashboardData = (dashboardState is DashboardLoaded) + ? dashboardState.dashboardData + : null; + final stats = dashboardData?.stats; - return BlocBuilder( - builder: (context, dashboardState) { - if (dashboardState is DashboardLoading) { - return const Center( - child: CircularProgressIndicator(color: UnionFlowColors.error), - ); - } - - final dashboardData = (dashboardState is DashboardLoaded) - ? dashboardState.dashboardData - : null; - final stats = dashboardData?.stats; - - return SingleChildScrollView( - padding: const EdgeInsets.all(24), + return RefreshIndicator( + color: UnionFlowColors.error, + strokeWidth: 2, + onRefresh: () async { + context.read().add(LoadDashboardData( + organizationId: dashboardData?.organizationId ?? '', + userId: user?.id ?? '', + useGlobalDashboard: true, + )); + await Future.delayed(const Duration(milliseconds: 600)); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // En-tête Root - AnimatedFadeIn( - delay: const Duration(milliseconds: 100), - child: _buildUserHeader(user), - ), - const SizedBox(height: 24), - - // Balance globale - AnimatedSlideIn( - delay: const Duration(milliseconds: 200), - child: UnionBalanceCard( - label: 'Caisse Globale Système', - amount: _formatAmount(stats?.totalContributionAmount ?? 0), - trend: stats != null && stats.monthlyGrowth != 0 - ? '${stats.monthlyGrowth > 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}% ce mois' - : 'Aucune variation', - isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, - ), - ), - const SizedBox(height: 24), - - // Stats système - AnimatedFadeIn( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Organisations', - value: stats != null ? '${stats.totalOrganizations ?? 0}' : '0', - icon: Icons.business_outlined, - color: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Utilisateurs', - value: '${stats?.totalMembers ?? 0}', - icon: Icons.groups_outlined, - color: UnionFlowColors.gold, - trend: stats != null && stats.monthlyGrowth > 0 - ? '+${stats.monthlyGrowth.toStringAsFixed(0)}%' - : null, - isTrendUp: (stats?.monthlyGrowth ?? 0) > 0, - ), - ), - ], + // ── Carte identité ─────────────────────────────── + UserIdentityCard( + initials: user?.initials ?? 'SA', + name: user?.fullName ?? 'Super Administrateur', + subtitle: 'Accès global système', + badgeLabel: 'ROOT', + gradient: const LinearGradient( + colors: [Color(0xFFB91C1C), Color(0xFF7F1D1D)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, ), + accentColor: UnionFlowColors.error, + showTopBorder: false, ), const SizedBox(height: 12), - AnimatedFadeIn( - delay: const Duration(milliseconds: 400), - child: Row( - children: [ - Expanded( - child: UnionStatWidget( - label: 'Projets', - value: '${stats?.completedProjects ?? 0}', - icon: Icons.account_balance_outlined, - color: UnionFlowColors.info, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionStatWidget( - label: 'Engagement', - value: stats != null - ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' - : '0%', - icon: Icons.trending_up, - color: (stats?.engagementRate ?? 0) >= 0.7 - ? UnionFlowColors.success - : UnionFlowColors.warning, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Répartition Membres (données réelles) - if (stats != null && stats.totalMembers > 0) - AnimatedFadeIn( - delay: const Duration(milliseconds: 500), - child: UnionPieChart( - title: 'Activité des Membres', - subtitle: '${stats.totalMembers} membres au total', - sections: [ - UnionPieChartSection.create( - value: stats.activeMembers.toDouble(), - color: UnionFlowColors.success, - title: '${((stats.activeMembers / stats.totalMembers) * 100).toStringAsFixed(0)}%\nActifs', - ), - UnionPieChartSection.create( - value: (stats.totalMembers - stats.activeMembers).toDouble(), - color: UnionFlowColors.warning, - title: '${(((stats.totalMembers - stats.activeMembers) / stats.totalMembers) * 100).toStringAsFixed(0)}%\nInactifs', - ), - ], - ), - ), - if (stats != null && stats.totalMembers > 0) - const SizedBox(height: 24), - - // Événements à venir (données réelles) - if (dashboardData != null && dashboardData.hasUpcomingEvents) - AnimatedFadeIn( - delay: const Duration(milliseconds: 600), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Événements à venir', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 12), - ...dashboardData.upcomingEvents.take(3).map((event) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border( - left: BorderSide(color: UnionFlowColors.unionGreen, width: 3), - ), - ), - child: Row( - children: [ - Icon(Icons.event, color: UnionFlowColors.unionGreen, size: 20), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.title, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - event.daysUntilEvent, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - Text( - '${event.currentParticipants}/${event.maxParticipants}', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textSecondary, - ), - ), - ], - ), - ), - )), - ], - ), - ), - if (dashboardData != null && dashboardData.hasUpcomingEvents) - const SizedBox(height: 24), - - // Activités récentes (données réelles) - if (dashboardData != null && dashboardData.hasRecentActivity) - AnimatedFadeIn( - delay: const Duration(milliseconds: 650), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Activités Récentes', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - const SizedBox(height: 12), - ...dashboardData.recentActivities.take(5).map((activity) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: UnionFlowColors.unionGreen.withOpacity(0.2), - child: Text( - activity.userName[0].toUpperCase(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: UnionFlowColors.unionGreen, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.title, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), - ), - Text( - activity.description, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Text( - activity.timeAgo, - style: const TextStyle( - fontSize: 10, - color: UnionFlowColors.textTertiary, - ), - ), - ], - ), - ), - )), - ], - ), - ), - if (dashboardData != null && dashboardData.hasRecentActivity) - const SizedBox(height: 24), - const SizedBox(height: 24), - - // Panel Root - AnimatedFadeIn( - delay: const Duration(milliseconds: 700), - child: const Text( - 'Panel Root', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: UnionFlowColors.textPrimary, - ), - ), - ), - const SizedBox(height: 16), - - AnimatedSlideIn( - delay: const Duration(milliseconds: 800), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Organisations', - icon: Icons.business, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const OrganizationsPageWrapper(), - ), - ); - }, - backgroundColor: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Utilisateurs', - icon: Icons.people, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const UserManagementPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.gold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Système', - icon: Icons.settings, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SystemSettingsPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.indigo, - ), - ), - ], - ), + // ── Balance principale ─────────────────────────── + UnionBalanceCard( + label: 'Caisse Globale Système', + amount: _formatAmount(stats?.totalContributionAmount ?? 0), + trend: stats != null && stats.totalContributionAmount > 0 + ? '${stats.monthlyGrowth >= 0 ? '+' : ''}${stats.monthlyGrowth.toStringAsFixed(1)}%' + : null, + isTrendPositive: (stats?.monthlyGrowth ?? 0) >= 0, ), const SizedBox(height: 12), - AnimatedSlideIn( - delay: const Duration(milliseconds: 900), - child: Row( - children: [ - Expanded( - child: UnionActionButton( - label: 'Backup', - icon: Icons.backup, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const BackupPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.warning, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Sécurité', - icon: Icons.security, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SystemSettingsPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.error, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UnionActionButton( - label: 'Support', - icon: Icons.help_outline, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const HelpSupportPage(), - ), - ); - }, - backgroundColor: UnionFlowColors.info, - ), - ), - ], + // ── Grille 3×2 KPIs ───────────────────────────── + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.0, + children: [ + UnionStatWidget( + label: 'Organisations', + value: '${stats?.totalOrganizations ?? 0}', + icon: Icons.business_rounded, + color: UnionFlowColors.unionGreen, + ), + UnionStatWidget( + label: 'Utilisateurs', + value: '${stats?.totalMembers ?? 0}', + icon: Icons.groups_rounded, + color: UnionFlowColors.gold, + trend: stats != null && stats.monthlyGrowth > 0 + ? '+${stats.monthlyGrowth.toStringAsFixed(0)}%' + : null, + ), + UnionStatWidget( + label: 'Événements', + value: '${stats?.totalEvents ?? 0}', + icon: Icons.event_rounded, + color: UnionFlowColors.info, + trend: stats != null && stats.upcomingEvents > 0 + ? '${stats.upcomingEvents} à venir' + : null, + ), + UnionStatWidget( + label: 'Cotisations', + value: '${stats?.totalContributions ?? 0}', + icon: Icons.payments_rounded, + color: UnionFlowColors.amber, + ), + UnionStatWidget( + label: 'Demandes', + value: '${stats?.pendingRequests ?? 0}', + icon: Icons.pending_actions_rounded, + color: (stats?.pendingRequests ?? 0) > 0 + ? UnionFlowColors.warning + : UnionFlowColors.success, + ), + UnionStatWidget( + label: 'Engagement', + value: stats != null + ? '${(stats.engagementRate * 100).toStringAsFixed(0)}%' + : '—', + icon: Icons.trending_up_rounded, + color: (stats?.engagementRate ?? 0) >= 0.7 + ? UnionFlowColors.success + : UnionFlowColors.warning, + ), + ], + ), + const SizedBox(height: 12), + + // ── Activité membres (pie compact) ─────────────── + if (stats != null && stats.totalMembers > 0) ...[ + _buildMemberActivityRow(stats), + const SizedBox(height: 12), + ], + + // ── Répartition types d'org ────────────────────── + if (stats?.organizationTypeDistribution != null && + stats!.organizationTypeDistribution!.isNotEmpty) ...[ + _buildOrgTypeRow(stats), + const SizedBox(height: 12), + ], + + // ── Événements à venir ─────────────────────────── + if (dashboardData != null && dashboardData.hasUpcomingEvents) ...[ + UFSectionHeader( + 'Événements à venir', + trailing: '${dashboardData.upcomingEvents.length} programmés', ), + const SizedBox(height: 6), + ...dashboardData.upcomingEvents.take(3).map( + (e) => DashboardEventRow( + title: e.title, + date: e.formattedDate, + daysUntil: e.daysUntilEvent, + participants: + '${e.currentParticipants}/${e.maxParticipants}', + ), + ), + const SizedBox(height: 12), + ], + + // ── Activités récentes ─────────────────────────── + if (dashboardData != null && dashboardData.hasRecentActivity) ...[ + const UFSectionHeader('Activités récentes', + trailing: 'Dernières actions'), + const SizedBox(height: 6), + ...dashboardData.recentActivities.take(5).map( + (a) => DashboardActivityRow( + title: a.title, + description: a.description, + timeAgo: a.timeAgo, + icon: DashboardActivityRow.iconFor(a.type), + color: DashboardActivityRow.colorFor(a.type), + ), + ), + const SizedBox(height: 12), + ], + + // ── Actions rapides ────────────────────────────── + const UFSectionHeader('Actions'), + const SizedBox(height: 8), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.8, + children: [ + UnionActionButton( + label: 'Organisations', + icon: Icons.business_rounded, + iconColor: UnionFlowColors.unionGreen, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const OrganizationsPageWrapper())), + ), + UnionActionButton( + label: 'Utilisateurs', + icon: Icons.people_rounded, + iconColor: UnionFlowColors.gold, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const UserManagementPage())), + ), + UnionActionButton( + label: 'Système', + icon: Icons.settings_rounded, + iconColor: UnionFlowColors.indigo, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const SystemSettingsPage())), + ), + UnionActionButton( + label: 'Backup', + icon: Icons.backup_rounded, + iconColor: UnionFlowColors.warning, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const BackupPage())), + ), + UnionActionButton( + label: 'Sécurité', + icon: Icons.security_rounded, + iconColor: UnionFlowColors.error, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const SystemSettingsPage())), + ), + UnionActionButton( + label: 'Support', + icon: Icons.help_outline_rounded, + iconColor: UnionFlowColors.info, + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const HelpSupportPage())), + ), + ], ), ], ), - ); - }, - ); - }, - ), + ), + ); + }, + ); + }, ), ); } - PreferredSizeWidget _buildAppBar() { + // ─── AppBar ──────────────────────────────────────────────────────────────── + + PreferredSizeWidget _buildAppBar(BuildContext context) { return AppBar( backgroundColor: UnionFlowColors.surface, elevation: 0, + scrolledUnderElevation: 1, + shadowColor: UnionFlowColors.border, + leading: Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu, size: 22, color: UnionFlowColors.textPrimary), + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), + ), title: Row( children: [ Container( - width: 32, - height: 32, + width: 28, + height: 28, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [UnionFlowColors.error, Colors.red.shade900], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(8), + color: UnionFlowColors.error, + borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: const Text( @@ -467,27 +291,26 @@ class SuperAdminDashboard extends StatelessWidget { style: TextStyle( color: Colors.white, fontWeight: FontWeight.w900, - fontSize: 18, + fontSize: 15, ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'UnionFlow', style: TextStyle( - fontSize: 16, + fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary, ), ), Text( - 'Super Admin (Root)', + 'Super Admin', style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, + fontSize: 10, color: UnionFlowColors.textSecondary, ), ), @@ -495,102 +318,173 @@ class SuperAdminDashboard extends StatelessWidget { ), ], ), - iconTheme: const IconThemeData(color: UnionFlowColors.textPrimary), actions: [ - UnionExportButton( - onExport: (exportType) {}, - ), - const SizedBox(width: 8), + UnionExportButton(onExport: (_) {}), + const SizedBox(width: 6), ], ); } - Widget _buildUserHeader(dynamic user) { + // ─── Activité membres ───────────────────────────────────────────────────── + + Widget _buildMemberActivityRow(DashboardStatsEntity stats) { + final total = stats.totalMembers; + final active = stats.activeMembers; + final inactive = total - active; + final pct = total > 0 ? active / total : 0.0; + return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [UnionFlowColors.error, Colors.red.shade900], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: const Border( - top: BorderSide(color: UnionFlowColors.error, width: 3), - ), - boxShadow: [ - BoxShadow( - color: UnionFlowColors.error.withOpacity(0.4), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: UnionFlowColors.border), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 28, - backgroundColor: Colors.white.withOpacity(0.3), - child: Text( - user?.initials ?? 'SA', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, + Row( + children: [ + const Text( + 'Activité membres', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, + ), ), + const Spacer(), + Text( + '$total membres', + style: const TextStyle(fontSize: 10, color: UnionFlowColors.textTertiary), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + minHeight: 6, + backgroundColor: UnionFlowColors.border, + valueColor: const AlwaysStoppedAnimation(UnionFlowColors.success), ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.fullName ?? 'Super Administrateur', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Global System Root Access', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'ROOT', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: UnionFlowColors.error, - letterSpacing: 0.5, + const SizedBox(height: 8), + Row( + children: [ + _dot(UnionFlowColors.success), + const SizedBox(width: 4), + const Text('Actifs', style: TextStyle(fontSize: 10, color: UnionFlowColors.textSecondary)), + const SizedBox(width: 4), + Text( + '$active (${(pct * 100).toStringAsFixed(0)}%)', + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), ), - ), + const Spacer(), + _dot(UnionFlowColors.border), + const SizedBox(width: 4), + const Text('Inactifs', style: TextStyle(fontSize: 10, color: UnionFlowColors.textSecondary)), + const SizedBox(width: 4), + Text( + '$inactive (${total > 0 ? ((inactive / total) * 100).toStringAsFixed(0) : 0}%)', + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + ], ), ], ), ); } + // ─── Répartition types d'org ────────────────────────────────────────────── + + Widget _buildOrgTypeRow(DashboardStatsEntity stats) { + final dist = stats.organizationTypeDistribution!; + final total = dist.values.fold(0, (s, v) => s + v); + const colors = [ + UnionFlowColors.unionGreen, + UnionFlowColors.gold, + UnionFlowColors.info, + UnionFlowColors.warning, + UnionFlowColors.error, + ]; + final entries = dist.entries.toList(); + + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: UnionFlowColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Types d\'organisation', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + const Spacer(), + Text('$total org.', style: const TextStyle(fontSize: 10, color: UnionFlowColors.textTertiary)), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Row( + children: entries.asMap().entries.map((e) { + final frac = total > 0 ? e.value.value / total : 0.0; + return Flexible( + flex: (frac * 100).round(), + child: Container(height: 6, color: colors[e.key % colors.length]), + ); + }).toList(), + ), + ), + const SizedBox(height: 8), + ...entries.asMap().entries.map((e) { + final color = colors[e.key % colors.length]; + final pct = total > 0 ? (e.value.value / total * 100).toStringAsFixed(0) : '0'; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + _dot(color), + const SizedBox(width: 6), + Expanded( + child: Text( + e.value.key, + style: const TextStyle(fontSize: 10, color: UnionFlowColors.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct% · ${e.value.value}', + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + ), + ], + ), + ); + }), + ], + ), + ); + } + + Widget _dot(Color color) => Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + + // ─── Helpers ────────────────────────────────────────────────────────────── + String _formatAmount(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; - } + if (amount >= 1000000) return '${(amount / 1000000).toStringAsFixed(1)}M FCFA'; + if (amount >= 1000) return '${(amount / 1000).toStringAsFixed(0)}K FCFA'; return '${amount.toStringAsFixed(0)} 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 384dc9f..b3b74e2 100644 --- a/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart +++ b/lib/features/dashboard/presentation/pages/role_dashboards/visitor_dashboard.dart @@ -23,7 +23,7 @@ class VisitorDashboard extends StatelessWidget { ), body: AfricanPatternBackground( child: SingleChildScrollView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -32,7 +32,7 @@ class VisitorDashboard extends StatelessWidget { delay: const Duration(milliseconds: 100), child: _buildWelcomeCard(), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Fonctionnalités UnionFlow AnimatedSlideIn( @@ -40,13 +40,13 @@ class VisitorDashboard extends StatelessWidget { child: const Text( 'Découvrez UnionFlow', style: TextStyle( - fontSize: 18, + fontSize: 13, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary, ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedFadeIn( delay: const Duration(milliseconds: 300), @@ -98,7 +98,7 @@ class VisitorDashboard extends StatelessWidget { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Avantages AnimatedSlideIn( @@ -106,13 +106,13 @@ class VisitorDashboard extends StatelessWidget { child: const Text( 'Nos Avantages', style: TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary, ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), AnimatedFadeIn( delay: const Duration(milliseconds: 600), @@ -156,45 +156,44 @@ class VisitorDashboard extends StatelessWidget { UnionFlowColors.gold, ), ), - const SizedBox(height: 32), + const SizedBox(height: 16), // Call to Action AnimatedSlideIn( delay: const Duration(milliseconds: 1000), child: Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(20), - boxShadow: UnionFlowColors.greenGlowShadow, + borderRadius: BorderRadius.circular(12), ), child: Column( children: [ const Icon( Icons.rocket_launch, - size: 48, + size: 24, color: Colors.white, ), - const SizedBox(height: 16), + const SizedBox(height: 8), const Text( 'Prêt à Commencer ?', style: TextStyle( - fontSize: 20, + fontSize: 15, fontWeight: FontWeight.w800, color: Colors.white, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( 'Rejoignez des milliers d\'organisations qui nous font confiance', style: TextStyle( - fontSize: 13, + fontSize: 11, color: Colors.white.withOpacity(0.9), ), textAlign: TextAlign.center, ), - const SizedBox(height: 20), + const SizedBox(height: 10), SizedBox( width: double.infinity, child: ElevatedButton( @@ -204,22 +203,22 @@ class VisitorDashboard extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: UnionFlowColors.unionGreen, - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), elevation: 0, ), child: const Text( 'Créer un Compte', style: TextStyle( - fontSize: 15, + fontSize: 13, fontWeight: FontWeight.w800, ), ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 6), TextButton( onPressed: () { Navigator.of(context).pushNamed('/login'); @@ -227,7 +226,7 @@ class VisitorDashboard extends StatelessWidget { child: Text( 'Déjà membre ? Se connecter', style: TextStyle( - fontSize: 13, + fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white.withOpacity(0.9), ), @@ -300,10 +299,10 @@ class VisitorDashboard extends StatelessWidget { Widget _buildWelcomeCard() { return Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( gradient: UnionFlowColors.subtleGradient, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(10), border: const Border( top: BorderSide(color: UnionFlowColors.unionGreen, width: 3), ), @@ -315,15 +314,15 @@ class VisitorDashboard extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( gradient: UnionFlowColors.primaryGradient, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.waving_hand, color: Colors.white, - size: 28, + size: 16, ), ), const SizedBox(width: 16), @@ -334,7 +333,7 @@ class VisitorDashboard extends StatelessWidget { Text( 'Bienvenue sur UnionFlow', style: TextStyle( - fontSize: 18, + fontSize: 14, fontWeight: FontWeight.w800, color: UnionFlowColors.textPrimary, ), @@ -343,7 +342,7 @@ class VisitorDashboard extends StatelessWidget { Text( 'Votre plateforme de gestion mutualiste et associative', style: TextStyle( - fontSize: 13, + fontSize: 11, color: UnionFlowColors.textSecondary, ), ), @@ -352,11 +351,11 @@ class VisitorDashboard extends StatelessWidget { ), ], ), - const SizedBox(height: 16), + 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( - fontSize: 14, + fontSize: 12, height: 1.5, color: UnionFlowColors.textPrimary.withOpacity(0.8), ), @@ -368,24 +367,23 @@ class VisitorDashboard extends StatelessWidget { Widget _buildFeature(String title, String description, IconData icon, Color color) { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(8), border: Border( - left: BorderSide(color: color, width: 4), + left: BorderSide(color: color, width: 3), ), - boxShadow: UnionFlowColors.softShadow, ), child: Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: color, size: 24), + child: Icon(icon, color: color, size: 15), ), const SizedBox(width: 16), Expanded( diff --git a/lib/features/dashboard/presentation/widgets/common/activity_item.dart b/lib/features/dashboard/presentation/widgets/common/activity_item.dart index 29b072d..767a4f7 100644 --- a/lib/features/dashboard/presentation/widgets/common/activity_item.dart +++ b/lib/features/dashboard/presentation/widgets/common/activity_item.dart @@ -183,7 +183,7 @@ class ActivityItem extends StatelessWidget { children: [ if (showStatusIndicator) ...[ Container( - padding: const EdgeInsets.all(6), + padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: effectiveColor.withOpacity(0.1), shape: BoxShape.circle, @@ -191,7 +191,7 @@ class ActivityItem extends StatelessWidget { child: Icon( effectiveIcon, color: effectiveColor, - size: 16, + size: 14, ), ), const SizedBox(width: 12), @@ -242,15 +242,15 @@ class ActivityItem extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: effectiveColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), ), child: Icon( effectiveIcon, color: effectiveColor, - size: 18, + size: 14, ), ), const SizedBox(width: 12), @@ -258,7 +258,7 @@ class ActivityItem extends StatelessWidget { child: Text( title, style: const TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1F2937), ), @@ -409,26 +409,12 @@ class ActivityItem extends StatelessWidget { case ActivityItemStyle.normal: return BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.02), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], + borderRadius: BorderRadius.circular(6), ); case ActivityItemStyle.detailed: return BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + borderRadius: BorderRadius.circular(8), ); case ActivityItemStyle.alert: return BoxDecoration( diff --git a/lib/features/dashboard/presentation/widgets/common/section_header.dart b/lib/features/dashboard/presentation/widgets/common/section_header.dart index ad0904b..c4aad4a 100644 --- a/lib/features/dashboard/presentation/widgets/common/section_header.dart +++ b/lib/features/dashboard/presentation/widgets/common/section_header.dart @@ -52,9 +52,9 @@ class SectionHeader extends StatelessWidget { this.action, this.icon, }) : color = ColorTokens.primary, - fontSize = 20, + fontSize = 16, style = SectionHeaderStyle.primary, - bottomSpacing = 16; + bottomSpacing = 10; /// Constructeur pour un en-tête de section const SectionHeader.section({ @@ -64,9 +64,9 @@ class SectionHeader extends StatelessWidget { this.action, this.icon, }) : color = ColorTokens.primary, - fontSize = 16, + fontSize = 13, style = SectionHeaderStyle.normal, - bottomSpacing = 12; + bottomSpacing = 8; /// Constructeur pour un en-tête de sous-section const SectionHeader.subsection({ @@ -106,7 +106,7 @@ class SectionHeader extends StatelessWidget { final effectiveColor = color ?? ColorTokens.primary; return Container( - padding: const EdgeInsets.all(SpacingTokens.lg), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( gradient: LinearGradient( colors: [ @@ -117,21 +117,20 @@ class SectionHeader extends StatelessWidget { end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), - boxShadow: ShadowTokens.primary, ), child: Row( children: [ if (icon != null) ...[ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), ), child: Icon( icon, color: Colors.white, - size: 20, + size: 16, ), ), const SizedBox(width: 12), @@ -143,7 +142,7 @@ class SectionHeader extends StatelessWidget { Text( title, style: TextStyle( - fontSize: fontSize ?? 20, + fontSize: fontSize ?? 16, fontWeight: FontWeight.bold, color: Colors.white, ), @@ -186,7 +185,7 @@ class SectionHeader extends StatelessWidget { Text( title, style: TextStyle( - fontSize: fontSize ?? 16, + fontSize: fontSize ?? 13, fontWeight: FontWeight.bold, color: color ?? ColorTokens.primary, ), @@ -239,17 +238,10 @@ class SectionHeader extends StatelessWidget { /// En-tête avec fond de carte Widget _buildCardHeader() { return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], ), child: Row( children: [ @@ -268,7 +260,7 @@ class SectionHeader extends StatelessWidget { Text( title, style: TextStyle( - fontSize: fontSize ?? 16, + fontSize: fontSize ?? 13, fontWeight: FontWeight.bold, color: color ?? ColorTokens.primary, ), diff --git a/lib/features/dashboard/presentation/widgets/common/stat_card.dart b/lib/features/dashboard/presentation/widgets/common/stat_card.dart index 45de13f..30617de 100644 --- a/lib/features/dashboard/presentation/widgets/common/stat_card.dart +++ b/lib/features/dashboard/presentation/widgets/common/stat_card.dart @@ -136,12 +136,12 @@ class StatCard extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: color, size: 20), + child: Icon(icon, color: color, size: 16), ), const Spacer(), Column( @@ -152,7 +152,7 @@ class StatCard extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, color: color, - fontSize: 20, + fontSize: 16, ), ), if (subtitle.isNotEmpty) @@ -167,13 +167,13 @@ class StatCard extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), Text( title, style: const TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF1F2937), - fontSize: 14, + fontSize: 13, ), ), ], @@ -188,12 +188,12 @@ class StatCard extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: color, size: 24), + child: Icon(icon, color: color, size: 16), ), const Spacer(), Column( @@ -204,7 +204,7 @@ class StatCard extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, color: color, - fontSize: 24, + fontSize: 18, ), ), if (subtitle.isNotEmpty) @@ -219,13 +219,13 @@ class StatCard extends StatelessWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( title, style: const TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF1F2937), - fontSize: 16, + fontSize: 13, ), ), ], @@ -240,7 +240,7 @@ class StatCard extends StatelessWidget { case StatCardSize.normal: return const EdgeInsets.all(12); case StatCardSize.large: - return const EdgeInsets.all(16); + return const EdgeInsets.all(10); } } @@ -256,13 +256,6 @@ class StatCard extends StatelessWidget { return BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], ); case StatCardStyle.outlined: return BoxDecoration( diff --git a/lib/features/dashboard/presentation/widgets/connected/connected_recent_activities.dart b/lib/features/dashboard/presentation/widgets/connected/connected_recent_activities.dart index 6f6612d..b0a79fd 100644 --- a/lib/features/dashboard/presentation/widgets/connected/connected_recent_activities.dart +++ b/lib/features/dashboard/presentation/widgets/connected/connected_recent_activities.dart @@ -24,12 +24,12 @@ class ConnectedRecentActivities extends StatelessWidget { @override Widget build(BuildContext context) { return CoreCard( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHeader(), - const SizedBox(height: 16), + const SizedBox(height: 8), BlocBuilder( builder: (context, state) { if (state is DashboardLoading) { @@ -204,11 +204,11 @@ class ConnectedRecentActivities extends StatelessWidget { return Row( children: [ Container( - width: 40, - height: 40, + width: 28, + height: 28, decoration: BoxDecoration( color: AppColors.lightBorder, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), ), ), const SizedBox(width: 12), diff --git a/lib/features/dashboard/presentation/widgets/connected/connected_stats_card.dart b/lib/features/dashboard/presentation/widgets/connected/connected_stats_card.dart index 15884e0..2966401 100644 --- a/lib/features/dashboard/presentation/widgets/connected/connected_stats_card.dart +++ b/lib/features/dashboard/presentation/widgets/connected/connected_stats_card.dart @@ -50,14 +50,14 @@ class ConnectedStatsCard extends StatelessWidget { return CoreCard( onTap: onTap, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8), @@ -65,10 +65,10 @@ class ConnectedStatsCard extends StatelessWidget { child: Icon( icon, color: color, - size: 20, + size: 16, ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Expanded( child: Text( title.toUpperCase(), @@ -83,12 +83,13 @@ class ConnectedStatsCard extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), Text( value, style: AppTypography.headerSmall.copyWith( color: color, fontWeight: FontWeight.bold, + fontSize: 18, ), ), if (subtitle != null) ...[ @@ -105,7 +106,7 @@ class ConnectedStatsCard extends StatelessWidget { Widget _buildLoadingCard() { return const CoreCard( - padding: EdgeInsets.all(16), + padding: EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -118,7 +119,7 @@ class ConnectedStatsCard extends StatelessWidget { Widget _buildErrorCard(String message) { return CoreCard( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/dashboard/presentation/widgets/connected/connected_upcoming_events.dart b/lib/features/dashboard/presentation/widgets/connected/connected_upcoming_events.dart index e74278b..dc5867b 100644 --- a/lib/features/dashboard/presentation/widgets/connected/connected_upcoming_events.dart +++ b/lib/features/dashboard/presentation/widgets/connected/connected_upcoming_events.dart @@ -19,12 +19,12 @@ class ConnectedUpcomingEvents extends StatelessWidget { @override Widget build(BuildContext context) { return CoreCard( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHeader(), - const SizedBox(height: 16), + const SizedBox(height: 8), BlocBuilder( builder: (ctx, state) { if (state is DashboardLoading) { @@ -103,29 +103,29 @@ class ConnectedUpcomingEvents extends StatelessWidget { return CoreCard( backgroundColor: Theme.of(context).cardColor, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( - width: 44, - height: 44, + width: 32, + height: 32, decoration: BoxDecoration( color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), ), child: event.imageUrl != null ? ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), child: Image.network( event.imageUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Icon(Icons.event_outlined, color: statusColor, size: 20), + errorBuilder: (context, error, stackTrace) => Icon(Icons.event_outlined, color: statusColor, size: 16), ), ) - : Icon(Icons.event_outlined, color: statusColor, size: 20), + : Icon(Icons.event_outlined, color: statusColor, size: 16), ), const SizedBox(width: 12), Expanded( @@ -168,7 +168,7 @@ class ConnectedUpcomingEvents extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/dashboard/presentation/widgets/dashboard_drawer.dart b/lib/features/dashboard/presentation/widgets/dashboard_drawer.dart index 10bd6b7..8039082 100644 --- a/lib/features/dashboard/presentation/widgets/dashboard_drawer.dart +++ b/lib/features/dashboard/presentation/widgets/dashboard_drawer.dart @@ -1,5 +1,4 @@ /// Widget de menu latéral (drawer) du dashboard -/// Navigation principale de l'application library dashboard_drawer; import 'package:flutter/material.dart'; @@ -10,59 +9,39 @@ import '../../../../shared/widgets/core_card.dart'; import '../../../../shared/widgets/mini_avatar.dart'; import '../../../authentication/presentation/bloc/auth_bloc.dart'; - import '../../../profile/presentation/pages/profile_page_wrapper.dart'; import '../../../notifications/presentation/pages/notifications_page_wrapper.dart'; import '../../../help/presentation/pages/help_support_page.dart'; import '../../../about/presentation/pages/about_page.dart'; -/// Widget de menu latéral (Drawer / Hamburger) -/// -/// Accessible via le bouton hamburger de l'AppBar. -/// Contient uniquement les menus « Mon Espace » : -/// - Mon Profil -/// - Notifications -/// - Aide & Support -/// - À propos -/// - Déconnexion +/// Drawer principal — Mon Espace +/// Profil · Notifications · Aide · À propos · Déconnexion class DashboardDrawer extends StatelessWidget { - /// Callback pour les actions de navigation nommée (optionnel, non utilisé en interne) final Function(String route)? onNavigate; - - /// Callback pour la déconnexion final VoidCallback? onLogout; - const DashboardDrawer({ - super.key, - this.onNavigate, - this.onLogout, - }); + const DashboardDrawer({super.key, this.onNavigate, this.onLogout}); @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return BlocBuilder( builder: (context, authState) { - if (authState is! AuthAuthenticated) { - return const Drawer(); - } - - final state = authState; + if (authState is! AuthAuthenticated) return const Drawer(); return Drawer( - backgroundColor: ColorTokens.background, + backgroundColor: + isDark ? AppColors.darkSurface : AppColors.lightBackground, child: SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── En-tête utilisateur (même style que MorePage) ────────────── - _buildUserProfile(state), + _buildUserProfile(context, authState), const SizedBox(height: SpacingTokens.md), - - // ── Section Mon Espace ───────────────────────────────────────── - _buildSectionTitle('Mon Espace'), - + _buildSectionTitle(context, 'Mon Espace'), _buildOptionTile( context: context, icon: Icons.person, @@ -78,7 +57,8 @@ class DashboardDrawer extends StatelessWidget { title: 'Notifications', subtitle: 'Gérer les notifications', onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NotificationsPageWrapper()), + MaterialPageRoute( + builder: (_) => const NotificationsPageWrapper()), ), ), _buildOptionTile( @@ -99,16 +79,13 @@ class DashboardDrawer extends StatelessWidget { MaterialPageRoute(builder: (_) => const AboutPage()), ), ), - const SizedBox(height: SpacingTokens.md), - - // ── Déconnexion ─────────────────────────────────────────────── _buildOptionTile( context: context, icon: Icons.logout, title: 'Déconnexion', subtitle: 'Se déconnecter de l\'application', - color: ColorTokens.error, + accentColor: AppColors.error, onTap: () { Navigator.pop(context); context.read().add(const AuthLogoutRequested()); @@ -123,36 +100,44 @@ class DashboardDrawer extends StatelessWidget { ); } - // ── Profil utilisateur (idem MorePage._buildUserProfile) ────────────────── - Widget _buildUserProfile(AuthAuthenticated state) { + Widget _buildUserProfile(BuildContext context, AuthAuthenticated state) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final nameColor = + isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final emailColor = + isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + final roleColor = + isDark ? AppColors.brandGreenLight : AppColors.primaryGreen; + return CoreCard( child: Row( children: [ MiniAvatar( - fallbackText: - state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U', - size: 40, + fallbackText: state.user.firstName.isNotEmpty + ? state.user.firstName[0].toUpperCase() + : 'U', + size: 32, imageUrl: state.user.avatar, ), - const SizedBox(width: 16), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${state.user.firstName} ${state.user.lastName}', - style: AppTypography.actionText, + style: AppTypography.actionText.copyWith(color: nameColor), ), Text( state.effectiveRole.displayName.toUpperCase(), style: AppTypography.badgeText.copyWith( - color: AppColors.primaryGreen, + color: roleColor, fontWeight: FontWeight.bold, ), ), Text( state.user.email, - style: AppTypography.subtitleSmall, + style: AppTypography.subtitleSmall.copyWith(color: emailColor), ), ], ), @@ -162,31 +147,38 @@ class DashboardDrawer extends StatelessWidget { ); } - // ── Titre de section (idem MorePage._buildSectionTitle) ─────────────────── - Widget _buildSectionTitle(String title) { + Widget _buildSectionTitle(BuildContext context, String title) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Padding( - padding: const EdgeInsets.only(top: 24, bottom: 8, left: 4), + padding: const EdgeInsets.only(top: 10, bottom: 6, left: 4), child: Text( title.toUpperCase(), style: AppTypography.subtitleSmall.copyWith( fontWeight: FontWeight.bold, letterSpacing: 1.1, - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ); } - // ── Tuile d'option (idem MorePage._buildOptionTile) ─────────────────────── Widget _buildOptionTile({ required BuildContext context, required IconData icon, required String title, required String subtitle, required VoidCallback onTap, - Color? color, + Color? accentColor, }) { - final effectiveColor = color ?? AppColors.primaryGreen; + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = accentColor ?? AppColors.primaryGreen; + final titleColor = accentColor != null + ? accentColor + : (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight); + final subtitleColor = + isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + final chevronColor = + isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return CoreCard( margin: const EdgeInsets.only(bottom: 8), @@ -194,40 +186,30 @@ class DashboardDrawer extends StatelessWidget { child: Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: effectiveColor.withOpacity(0.1), + color: accent.withOpacity(isDark ? 0.2 : 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - icon, - color: effectiveColor, - size: 20, - ), + child: Icon(icon, color: accent, size: 16), ), - const SizedBox(width: 16), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: AppTypography.actionText.copyWith( - color: color ?? AppColors.textPrimaryLight, - ), + style: AppTypography.actionText.copyWith(color: titleColor), ), Text( subtitle, - style: AppTypography.subtitleSmall, + style: AppTypography.subtitleSmall.copyWith(color: subtitleColor), ), ], ), ), - Icon( - Icons.chevron_right, - color: AppColors.textSecondaryLight, - size: 16, - ), + Icon(Icons.chevron_right, color: chevronColor, size: 16), ], ), ); diff --git a/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart b/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart index 27c3ab9..bac3993 100644 --- a/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart +++ b/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart @@ -239,12 +239,17 @@ class DashboardActivity extends StatelessWidget { ], ), ), - Text( - time, - style: AppTypography.subtitleSmall.copyWith( - color: AppColors.textSecondaryLight, - fontSize: 9, - ), + Builder( + builder: (ctx) { + final isDark = Theme.of(ctx).brightness == Brightness.dark; + return Text( + time, + style: AppTypography.subtitleSmall.copyWith( + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + fontSize: 9, + ), + ); + }, ), ], ), diff --git a/lib/features/dashboard/presentation/widgets/metrics/real_time_metrics_widget.dart b/lib/features/dashboard/presentation/widgets/metrics/real_time_metrics_widget.dart index 1c62877..1b0de7a 100644 --- a/lib/features/dashboard/presentation/widgets/metrics/real_time_metrics_widget.dart +++ b/lib/features/dashboard/presentation/widgets/metrics/real_time_metrics_widget.dart @@ -88,21 +88,14 @@ class _RealTimeMetricsWidgetState extends State end: Alignment.bottomRight, colors: [AppColors.brandGreen, AppColors.primaryGreen], ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGreen.withOpacity(0.3), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], + borderRadius: BorderRadius.circular(10), ), - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHeader(), - const SizedBox(height: 20), + const SizedBox(height: 10), BlocConsumer( listener: (context, state) { if (state is DashboardLoaded) { @@ -137,7 +130,7 @@ class _RealTimeMetricsWidgetState extends State return Transform.scale( scale: _pulseAnimation.value, child: Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(4), @@ -145,7 +138,7 @@ class _RealTimeMetricsWidgetState extends State child: const Icon( Icons.speed_outlined, color: Colors.white, - size: 20, + size: 16, ), ), ); @@ -248,7 +241,7 @@ class _RealTimeMetricsWidgetState extends State ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -297,10 +290,10 @@ class _RealTimeMetricsWidgetState extends State } return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), border: Border.all( color: Colors.white.withOpacity(0.2), ), @@ -313,9 +306,9 @@ class _RealTimeMetricsWidgetState extends State Icon( icon, color: color, - size: 16, + size: 14, ), - const SizedBox(width: 8), + const SizedBox(width: 6), Expanded( child: Text( label.toUpperCase(), @@ -327,13 +320,13 @@ class _RealTimeMetricsWidgetState extends State ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( displayValue, style: AppTypography.headerSmall.copyWith( color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 20, + fontSize: 16, ), ), if (maxValue != null) ...[ @@ -357,15 +350,15 @@ class _RealTimeMetricsWidgetState extends State Row( children: [ Expanded(child: _buildLoadingMetricItem()), - const SizedBox(width: 16), + const SizedBox(width: 8), Expanded(child: _buildLoadingMetricItem()), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ Expanded(child: _buildLoadingMetricItem()), - const SizedBox(width: 16), + const SizedBox(width: 8), Expanded(child: _buildLoadingMetricItem()), ], ), @@ -375,11 +368,11 @@ class _RealTimeMetricsWidgetState extends State Widget _buildLoadingMetricItem() { return Container( - height: 100, - padding: const EdgeInsets.all(16), + height: 72, + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(6), ), child: const Center( child: CircularProgressIndicator( @@ -392,10 +385,10 @@ class _RealTimeMetricsWidgetState extends State Widget _buildErrorMetrics() { return Container( - height: 200, + height: 120, decoration: BoxDecoration( color: AppColors.error.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: Center( child: Column( @@ -421,10 +414,10 @@ class _RealTimeMetricsWidgetState extends State Widget _buildEmptyMetrics() { return Container( - height: 200, + height: 120, decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: Center( child: Column( diff --git a/lib/features/epargne/presentation/pages/epargne_detail_page.dart b/lib/features/epargne/presentation/pages/epargne_detail_page.dart index f5022ec..927d973 100644 --- a/lib/features/epargne/presentation/pages/epargne_detail_page.dart +++ b/lib/features/epargne/presentation/pages/epargne_detail_page.dart @@ -5,6 +5,7 @@ import '../../../../core/utils/logger.dart'; import '../../data/models/compte_epargne_model.dart'; import '../../data/models/transaction_epargne_model.dart'; import '../../data/repositories/transaction_epargne_repository.dart'; // CompteEpargneRepository + TransactionEpargneRepository +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../widgets/depot_epargne_dialog.dart'; import '../widgets/retrait_epargne_dialog.dart'; @@ -183,15 +184,8 @@ class _EpargneDetailPageState extends State { ), body: Container( width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - const Color(0xFFfafaf9), - const Color(0xFFfafaf9).withOpacity(0.85), - ], - ), + decoration: const BoxDecoration( + color: AppColors.lightBackground, ), child: SafeArea( child: RefreshIndicator( @@ -201,7 +195,7 @@ class _EpargneDetailPageState extends State { }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(SpacingTokens.lg), + padding: const EdgeInsets.all(SpacingTokens.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/features/epargne/presentation/pages/epargne_page.dart b/lib/features/epargne/presentation/pages/epargne_page.dart index b259298..9560625 100644 --- a/lib/features/epargne/presentation/pages/epargne_page.dart +++ b/lib/features/epargne/presentation/pages/epargne_page.dart @@ -409,10 +409,10 @@ class _EpargnePageState extends State { return RefreshIndicator( onRefresh: _loadComptes, child: ListView( - padding: const EdgeInsets.all(SpacingTokens.lg), + padding: const EdgeInsets.all(SpacingTokens.sm), children: [ _buildRecapCard(), - const SizedBox(height: SpacingTokens.lg), + const SizedBox(height: SpacingTokens.sm), ..._comptes.map(_buildCompteCard), ], ), diff --git a/lib/features/events/bloc/evenements_bloc.dart b/lib/features/events/bloc/evenements_bloc.dart index 6d8adfe..5bda5dc 100644 --- a/lib/features/events/bloc/evenements_bloc.dart +++ b/lib/features/events/bloc/evenements_bloc.dart @@ -81,14 +81,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur inattendue lors du chargement des événements: $e', + message: 'Erreur lors du chargement des événements. Veuillez réessayer.', error: e, )); } @@ -113,14 +115,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement de l\'événement: $e', + message: 'Erreur lors du chargement de l\'événement. Veuillez réessayer.', error: e, )); } @@ -138,6 +142,7 @@ class EvenementsBloc extends Bloc { emit(EvenementCreated(evenement)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; if (e.response?.statusCode == 400) { final errors = _extractValidationErrors(e.response?.data); emit(EvenementsValidationError( @@ -153,8 +158,9 @@ class EvenementsBloc extends Bloc { )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors de la création de l\'événement: $e', + message: 'Erreur lors de la création de l\'événement. Veuillez réessayer.', error: e, )); } @@ -172,6 +178,7 @@ class EvenementsBloc extends Bloc { emit(EvenementUpdated(evenement)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; if (e.response?.statusCode == 400) { final errors = _extractValidationErrors(e.response?.data); emit(EvenementsValidationError( @@ -187,8 +194,9 @@ class EvenementsBloc extends Bloc { )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors de la mise à jour de l\'événement: $e', + message: 'Erreur lors de la mise à jour de l\'événement. Veuillez réessayer.', error: e, )); } @@ -206,14 +214,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors de la suppression de l\'événement: $e', + message: 'Erreur lors de la suppression de l\'événement. Veuillez réessayer.', error: e, )); } @@ -240,14 +250,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement des événements à venir: $e', + message: 'Erreur lors du chargement des événements à venir. Veuillez réessayer.', error: e, )); } @@ -274,14 +286,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement des événements en cours: $e', + message: 'Erreur lors du chargement des événements en cours. Veuillez réessayer.', error: e, )); } @@ -308,14 +322,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement des événements passés: $e', + message: 'Erreur lors du chargement des événements passés. Veuillez réessayer.', error: e, )); } @@ -333,14 +349,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors de l\'inscription à l\'événement: $e', + message: 'Erreur lors de l\'inscription à l\'événement. Veuillez réessayer.', error: e, )); } @@ -358,14 +376,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors de la désinscription de l\'événement: $e', + message: 'Erreur lors de la désinscription de l\'événement. Veuillez réessayer.', error: e, )); } @@ -386,14 +406,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement des participants: $e', + message: 'Erreur lors du chargement des participants. Veuillez réessayer.', error: e, )); } @@ -411,14 +433,16 @@ 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, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(EvenementsError( - message: 'Erreur lors du chargement des statistiques: $e', + message: 'Erreur lors du chargement des statistiques. Veuillez réessayer.', error: e, )); } diff --git a/lib/features/events/data/repositories/evenement_repository_impl.dart b/lib/features/events/data/repositories/evenement_repository_impl.dart index 7dbf91f..3cdbae6 100644 --- a/lib/features/events/data/repositories/evenement_repository_impl.dart +++ b/lib/features/events/data/repositories/evenement_repository_impl.dart @@ -119,14 +119,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des événements: ${response.statusCode}'); } - } on DioException catch (e) { - if (e.response != null) { - throw Exception('Erreur HTTP ${e.response!.statusCode}: ${e.response!.data}'); - } else { - throw Exception('Erreur réseau: ${e.type} - ${e.message ?? e.error}'); - } + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des événements: $e'); + rethrow; } } @@ -143,12 +139,13 @@ class EvenementRepositoryImpl implements IEvenementRepository { throw Exception('Erreur lors de la récupération de l\'événement: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { return null; } - throw Exception('Erreur réseau lors de la récupération de l\'événement: ${e.message}'); + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération de l\'événement: $e'); + rethrow; } } @@ -165,10 +162,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la création de l\'événement: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la création de l\'événement: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la création de l\'événement: $e'); + rethrow; } } @@ -185,10 +182,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la mise à jour de l\'événement: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la mise à jour de l\'événement: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la mise à jour de l\'événement: $e'); + rethrow; } } @@ -200,10 +197,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { if (response.statusCode != 204 && response.statusCode != 200) { throw Exception('Erreur lors de la suppression de l\'événement: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la suppression de l\'événement: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la suppression de l\'événement: $e'); + rethrow; } } @@ -220,10 +217,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des événements à venir: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des événements à venir: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des événements à venir: $e'); + rethrow; } } @@ -240,10 +237,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des événements en cours: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des événements en cours: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des événements en cours: $e'); + rethrow; } } @@ -260,10 +257,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des événements passés: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des événements passés: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des événements passés: $e'); + rethrow; } } @@ -275,10 +272,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception('Erreur lors de l\'inscription à l\'événement: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de l\'inscription à l\'événement: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de l\'inscription à l\'événement: $e'); + rethrow; } } @@ -290,10 +287,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { if (response.statusCode != 200 && response.statusCode != 204) { throw Exception('Erreur lors de la désinscription de l\'événement: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la désinscription de l\'événement: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la désinscription de l\'événement: $e'); + rethrow; } } @@ -309,10 +306,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des participants: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des participants: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des participants: $e'); + rethrow; } } @@ -325,7 +322,8 @@ class EvenementRepositoryImpl implements IEvenementRepository { return data['inscrit'] == true; } return false; - } on DioException catch (_) { + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; return false; } catch (_) { return false; @@ -342,10 +340,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des statistiques: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des statistiques: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des statistiques: $e'); + rethrow; } } @@ -368,14 +366,15 @@ class EvenementRepositoryImpl implements IEvenementRepository { throw Exception('Erreur lors de la soumission du feedback: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 400) { // Erreur métier (déjà soumis, pas participant, etc.) final errorMsg = e.response?.data['error'] ?? 'Erreur lors de la soumission du feedback'; throw Exception(errorMsg); } - throw Exception('Erreur réseau lors de la soumission du feedback: ${e.message}'); + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la soumission du feedback: $e'); + rethrow; } } @@ -389,10 +388,10 @@ class EvenementRepositoryImpl implements IEvenementRepository { } else { throw Exception('Erreur lors de la récupération des feedbacks: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des feedbacks: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des feedbacks: $e'); + rethrow; } } } diff --git a/lib/features/events/presentation/pages/event_detail_page.dart b/lib/features/events/presentation/pages/event_detail_page.dart index dcc592a..714703a 100644 --- a/lib/features/events/presentation/pages/event_detail_page.dart +++ b/lib/features/events/presentation/pages/event_detail_page.dart @@ -2,6 +2,7 @@ library event_detail_page; import 'package:flutter/material.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/di/injection.dart'; import '../../bloc/evenements_bloc.dart'; @@ -52,7 +53,7 @@ class _EventDetailPageState extends State { return Scaffold( appBar: AppBar( title: const Text('Détails de l\'événement'), - backgroundColor: const Color(0xFF3B82F6), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, actions: [ IconButton( @@ -85,12 +86,12 @@ class _EventDetailPageState extends State { Widget _buildHeader() { return Container( width: double.infinity, - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( colors: [ - const Color(0xFF3B82F6), - const Color(0xFF3B82F6).withOpacity(0.8), + AppColors.primaryGreen, + AppColors.primaryGreen.withOpacity(0.8), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -100,10 +101,10 @@ class _EventDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), ), child: Text( _getTypeLabel(widget.evenement.type), @@ -150,7 +151,7 @@ class _EventDetailPageState extends State { Widget _buildInfoSection() { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( children: [ _buildInfoRow( @@ -190,7 +191,7 @@ class _EventDetailPageState extends State { padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ - Icon(icon, color: const Color(0xFF3B82F6), size: 20), + Icon(icon, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 12), Expanded( child: Column( @@ -223,14 +224,14 @@ class _EventDetailPageState extends State { if (widget.evenement.description == null) return const SizedBox.shrink(); return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Description', style: TextStyle( - fontSize: 18, + fontSize: 14, fontWeight: FontWeight.bold, ), ), @@ -246,21 +247,21 @@ class _EventDetailPageState extends State { Widget _buildLocationSection() { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Lieu', style: TextStyle( - fontSize: 18, + fontSize: 14, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Row( children: [ - const Icon(Icons.location_on, color: Color(0xFF3B82F6)), + const Icon(Icons.location_on, color: AppColors.primaryGreen), const SizedBox(width: 8), Expanded( child: Text( @@ -277,7 +278,7 @@ class _EventDetailPageState extends State { Widget _buildParticipantsSection() { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -287,7 +288,7 @@ class _EventDetailPageState extends State { const Text( 'Participants', style: TextStyle( - fontSize: 18, + fontSize: 14, fontWeight: FontWeight.bold, ), ), @@ -334,7 +335,7 @@ class _EventDetailPageState extends State { if (!isComplet) { return FloatingActionButton.extended( onPressed: () => _showInscriptionDialog(context, isInscrit), - backgroundColor: const Color(0xFF3B82F6), + backgroundColor: AppColors.primaryGreen, icon: const Icon(Icons.check), label: const Text('S\'inscrire'), ); @@ -424,17 +425,17 @@ class _EventDetailPageState extends State { Color _getStatutColor(StatutEvenement statut) { switch (statut) { case StatutEvenement.planifie: - return Colors.blue; + return AppColors.info; case StatutEvenement.confirme: - return Colors.green; + return AppColors.success; case StatutEvenement.enCours: - return Colors.orange; + return AppColors.warning; case StatutEvenement.termine: - return Colors.grey; + return AppColors.textSecondaryLight; case StatutEvenement.annule: - return Colors.red; + return AppColors.error; case StatutEvenement.reporte: - return Colors.purple; + return AppColors.brandGreen; } } } diff --git a/lib/features/events/presentation/pages/events_page_connected.dart b/lib/features/events/presentation/pages/events_page_connected.dart index 1af40f7..051ad5d 100644 --- a/lib/features/events/presentation/pages/events_page_connected.dart +++ b/lib/features/events/presentation/pages/events_page_connected.dart @@ -114,7 +114,7 @@ class _EventsPageWithDataState extends State with TickerProv final total = widget.totalCount; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: UnionFlowColors.surface, border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), @@ -224,9 +224,9 @@ class _EventsPageWithDataState extends State with TickerProv onRefresh: () async => context.read().add(const LoadEvenements()), color: UnionFlowColors.unionGreen, child: ListView.separated( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), + separatorBuilder: (_, __) => const SizedBox(height: 6), itemBuilder: (context, index) => _buildEventCard(filtered[index]), ), ); @@ -238,11 +238,10 @@ class _EventsPageWithDataState extends State with TickerProv return GestureDetector( onTap: () => _showEventDetails(event), child: Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), border: Border(left: BorderSide(color: _getStatutColor(event.statut), width: 4)), ), child: Column( @@ -331,11 +330,11 @@ class _EventsPageWithDataState extends State with TickerProv mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), decoration: const BoxDecoration(color: UnionFlowColors.goldPale, shape: BoxShape.circle), - child: const Icon(Icons.event_busy, size: 64, color: UnionFlowColors.gold), + child: const Icon(Icons.event_busy, size: 40, color: UnionFlowColors.gold), ), - const SizedBox(height: 24), + const SizedBox(height: 12), Text('Aucun événement $type', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary)), const SizedBox(height: 8), Text(_searchQuery.isEmpty ? 'La liste est vide pour le moment' : 'Essayez une autre recherche', style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary)), diff --git a/lib/features/explore/data/repositories/network_repository.dart b/lib/features/explore/data/repositories/network_repository.dart index 4cf7e7e..cdd226a 100644 --- a/lib/features/explore/data/repositories/network_repository.dart +++ b/lib/features/explore/data/repositories/network_repository.dart @@ -33,6 +33,7 @@ class NetworkRepository { final data = _parseListResponse(response.data); return data.map((json) => _memberFromJson(json as Map)).toList(); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 400) return []; rethrow; } @@ -49,6 +50,7 @@ class NetworkRepository { final data = _parseListResponse(response.data); return data.map((json) => _organisationFromJson(json as Map)).toList(); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 400) return []; rethrow; } @@ -83,6 +85,7 @@ class NetworkRepository { if (data is! List) return []; return data.map((e) => e.toString()).toList(); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 401 || e.response?.statusCode == 403) return []; AppLogger.error('NetworkRepository: getFollowedIds échoué', error: e); rethrow; @@ -98,6 +101,7 @@ class NetworkRepository { } return false; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('NetworkRepository: follow échoué', error: e, stackTrace: st); rethrow; } @@ -112,6 +116,7 @@ class NetworkRepository { } return false; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('NetworkRepository: unfollow échoué', error: e, stackTrace: st); rethrow; } diff --git a/lib/features/explore/presentation/bloc/network_bloc.dart b/lib/features/explore/presentation/bloc/network_bloc.dart index 777c4ff..42fed6d 100644 --- a/lib/features/explore/presentation/bloc/network_bloc.dart +++ b/lib/features/explore/presentation/bloc/network_bloc.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; @@ -46,6 +47,7 @@ class NetworkBloc extends Bloc { }).toList(); emit(NetworkLoaded(items: items, currentQuery: current.currentQuery)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NetworkBloc: toggle follow échoué', error: e, stackTrace: st); emit(const NetworkError('Impossible de mettre à jour le suivi. Réessayez.')); } @@ -58,6 +60,7 @@ class NetworkBloc extends Bloc { final items = await _repository.search('', followedIds: followedIds.toSet()); emit(NetworkLoaded(items: items, currentQuery: '')); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NetworkBloc: chargement réseau échoué', error: e, stackTrace: st); emit(NetworkError('Erreur chargement réseau : $e')); } @@ -76,6 +79,7 @@ class NetworkBloc extends Bloc { final items = await _repository.search(event.query, followedIds: followedIds.toSet()); emit(NetworkLoaded(items: items, currentQuery: event.query)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NetworkBloc: recherche réseau échouée', error: e, stackTrace: st); emit(NetworkError('Erreur de recherche : $e')); } diff --git a/lib/features/feed/presentation/bloc/unified_feed_bloc.dart b/lib/features/feed/presentation/bloc/unified_feed_bloc.dart index 1634bca..63b0fc7 100644 --- a/lib/features/feed/presentation/bloc/unified_feed_bloc.dart +++ b/lib/features/feed/presentation/bloc/unified_feed_bloc.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../../../../core/utils/logger.dart'; @@ -38,6 +39,7 @@ class UnifiedFeedBloc extends Bloc { emit(UnifiedFeedLoaded(items: items, hasReachedMax: hasReachedMax)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(UnifiedFeedError('Erreur de chargement du flux: $e')); } } @@ -59,6 +61,7 @@ class UnifiedFeedBloc extends Bloc { isFetchingMore: false, )); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('UnifiedFeedBloc: chargement supplémentaire échoué', error: e, stackTrace: st); emit(currentState.copyWith( isFetchingMore: false, diff --git a/lib/features/finance_workflow/data/datasources/finance_workflow_remote_datasource.dart b/lib/features/finance_workflow/data/datasources/finance_workflow_remote_datasource.dart index 92784d1..e93538f 100644 --- a/lib/features/finance_workflow/data/datasources/finance_workflow_remote_datasource.dart +++ b/lib/features/finance_workflow/data/datasources/finance_workflow_remote_datasource.dart @@ -22,7 +22,7 @@ class FinanceWorkflowRemoteDatasource { /// Headers HTTP avec authentification Future> _getHeaders() async { - final token = await secureStorage.read(key: 'access_token'); + final token = await secureStorage.read(key: 'kc_access'); return { 'Content-Type': 'application/json', 'Accept': 'application/json', diff --git a/lib/features/finance_workflow/presentation/pages/budgets_list_page.dart b/lib/features/finance_workflow/presentation/pages/budgets_list_page.dart index b623875..f1a5958 100644 --- a/lib/features/finance_workflow/presentation/pages/budgets_list_page.dart +++ b/lib/features/finance_workflow/presentation/pages/budgets_list_page.dart @@ -184,7 +184,7 @@ class _BudgetsListView extends StatelessWidget { children: [ Icon( Icons.account_balance_wallet_outlined, - size: 80, + size: 48, color: AppColors.textSecondaryLight.withOpacity(0.5), ), const SizedBox(height: SpacingTokens.lg), @@ -206,7 +206,7 @@ class _BudgetsListView extends StatelessWidget { children: [ Icon( Icons.error_outline, - size: 80, + size: 48, color: AppColors.error.withOpacity(0.5), ), const SizedBox(height: SpacingTokens.lg), diff --git a/lib/features/finance_workflow/presentation/pages/pending_approvals_page.dart b/lib/features/finance_workflow/presentation/pages/pending_approvals_page.dart index be65dee..b7ade4d 100644 --- a/lib/features/finance_workflow/presentation/pages/pending_approvals_page.dart +++ b/lib/features/finance_workflow/presentation/pages/pending_approvals_page.dart @@ -161,7 +161,7 @@ class _PendingApprovalsView extends StatelessWidget { children: [ Icon( Icons.error_outline, - size: 80, + size: 48, color: AppColors.error.withOpacity(0.5), ), const SizedBox(height: SpacingTokens.lg), @@ -194,7 +194,7 @@ class _PendingApprovalsView extends StatelessWidget { children: [ Icon( Icons.check_circle_outline, - size: 80, + size: 48, color: AppColors.success.withOpacity(0.5), ), const SizedBox(height: SpacingTokens.lg), diff --git a/lib/features/help/presentation/pages/help_support_page.dart b/lib/features/help/presentation/pages/help_support_page.dart index 12fca59..45c412c 100644 --- a/lib/features/help/presentation/pages/help_support_page.dart +++ b/lib/features/help/presentation/pages/help_support_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../../../shared/widgets/core_card.dart'; @@ -40,34 +41,34 @@ class _HelpSupportPageState extends State { backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: const UFAppBar(title: 'AIDE & SUPPORT'), body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header harmonisé _buildHeader(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Barre de recherche _buildSearchSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Actions rapides _buildQuickActionsSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Catégories FAQ _buildCategoriesSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // FAQ _buildFAQSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Guides et tutoriels _buildGuidesSection(), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Contact support _buildContactSection(), const SizedBox(height: 80), @@ -83,18 +84,18 @@ class _HelpSupportPageState extends State { child: Column( children: [ Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.primaryGreen.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.help_outline, color: AppColors.primaryGreen, - size: 48, + size: 32, ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( 'COMMENT POUVONS-NOUS VOUS AIDER ?', style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.bold), @@ -515,7 +516,7 @@ class _HelpSupportPageState extends State { _contactByEmail(); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Envoyer un email'), @@ -546,7 +547,7 @@ class _HelpSupportPageState extends State { _launchUrl('mailto:support@unionflow.com?subject=Rapport de bug - UnionFlow Mobile'); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE17055), + backgroundColor: AppColors.error, foregroundColor: Colors.white, ), child: const Text('Signaler'), @@ -577,7 +578,7 @@ class _HelpSupportPageState extends State { _launchUrl('mailto:support@unionflow.com?subject=Demande de fonctionnalité - UnionFlow Mobile'); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0984E3), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Envoyer'), @@ -612,7 +613,7 @@ class _HelpSupportPageState extends State { _launchUrl('https://docs.unionflow.com/$guideId'); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Voir en ligne'), @@ -643,7 +644,7 @@ class _HelpSupportPageState extends State { _contactByEmail(); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Contacter le support'), @@ -672,7 +673,7 @@ class _HelpSupportPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFFE74C3C), + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); @@ -683,7 +684,7 @@ class _HelpSupportPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); diff --git a/lib/features/logs/presentation/bloc/logs_monitoring_bloc.dart b/lib/features/logs/presentation/bloc/logs_monitoring_bloc.dart index b1fad81..6046c90 100644 --- a/lib/features/logs/presentation/bloc/logs_monitoring_bloc.dart +++ b/lib/features/logs/presentation/bloc/logs_monitoring_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des logs et du monitoring library logs_monitoring_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:equatable/equatable.dart'; @@ -105,6 +106,7 @@ class LogsMonitoringBloc extends Bloc ); emit(LogsLoaded(logs)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('LogsMonitoringBloc: searchLogs échoué', error: e, stackTrace: st); emit(LogsMonitoringError('Erreur: ${e.toString()}')); } @@ -116,6 +118,7 @@ class LogsMonitoringBloc extends Bloc final metrics = await _repository.getMetrics(); emit(MetricsLoaded(metrics)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('LogsMonitoringBloc: loadMetrics échoué', error: e, stackTrace: st); emit(LogsMonitoringError('Erreur: ${e.toString()}')); } @@ -127,6 +130,7 @@ class LogsMonitoringBloc extends Bloc final alerts = await _repository.getAlerts(); emit(AlertsLoaded(alerts)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('LogsMonitoringBloc: loadAlerts échoué', error: e, stackTrace: st); emit(LogsMonitoringError('Erreur: ${e.toString()}')); } @@ -140,6 +144,7 @@ class LogsMonitoringBloc extends Bloc emit(AlertsLoaded(alerts)); emit(LogsMonitoringSuccess('Alerte acquittée')); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('LogsMonitoringBloc: acknowledgeAlert échoué', error: e, stackTrace: st); emit(LogsMonitoringError('Erreur: ${e.toString()}')); } diff --git a/lib/features/logs/presentation/pages/logs_page.dart b/lib/features/logs/presentation/pages/logs_page.dart index 8e54919..6b831e3 100644 --- a/lib/features/logs/presentation/pages/logs_page.dart +++ b/lib/features/logs/presentation/pages/logs_page.dart @@ -171,8 +171,8 @@ class _LogsPageState extends State /// Header avec métriques système en temps réel Widget _buildHeader() { return Container( - margin: const EdgeInsets.all(SpacingTokens.lg), - padding: const EdgeInsets.all(SpacingTokens.xl), + margin: const EdgeInsets.symmetric(horizontal: SpacingTokens.sm, vertical: SpacingTokens.xs), + padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( gradient: const LinearGradient( colors: ColorTokens.primaryGradient, @@ -193,14 +193,14 @@ class _LogsPageState extends State Row( children: [ Container( - padding: const EdgeInsets.all(SpacingTokens.lg), + padding: const EdgeInsets.all(SpacingTokens.sm), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), - child: const Icon(Icons.monitor_heart, color: Colors.white, size: 24), + child: const Icon(Icons.monitor_heart, color: Colors.white, size: 20), ), - const SizedBox(width: SpacingTokens.xl), + const SizedBox(width: SpacingTokens.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -248,7 +248,7 @@ class _LogsPageState extends State ), ], ), - const SizedBox(height: SpacingTokens.xl), + const SizedBox(height: SpacingTokens.sm), // Métriques système en temps réel Row( children: [ @@ -259,7 +259,7 @@ class _LogsPageState extends State Expanded(child: UFMetricCard(label: 'Réseau', value: '${_systemMetrics['network']?.toStringAsFixed(1)} MB/s', icon: Icons.network_check, color: ColorTokens.info)), ], ), - const SizedBox(height: SpacingTokens.lg), + const SizedBox(height: SpacingTokens.sm), Row( children: [ Expanded(child: UFMetricCard(label: 'Connexions', value: '${_systemMetrics['activeConnections']}', icon: Icons.people, color: ColorTokens.success)), diff --git a/lib/features/members/bloc/membres_bloc.dart b/lib/features/members/bloc/membres_bloc.dart index be80ee2..7736848 100644 --- a/lib/features/members/bloc/membres_bloc.dart +++ b/lib/features/members/bloc/membres_bloc.dart @@ -6,6 +6,8 @@ import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import 'membres_event.dart'; import 'membres_state.dart'; +import '../../../shared/models/membre_search_criteria.dart'; +import '../../../shared/models/membre_search_result.dart'; import '../domain/usecases/get_members.dart'; import '../domain/usecases/get_member_by_id.dart'; import '../domain/usecases/create_member.dart' as uc; @@ -64,11 +66,25 @@ class MembresBloc extends Bloc { emit(const MembresLoading()); } - final result = await _getMembers( - page: event.page, - size: event.size, - recherche: event.recherche, - ); + final MembreSearchResult result; + if (event.organisationId != null) { + // OrgAdmin : scope la requête à son organisation via la recherche avancée + result = await _searchMembers( + criteria: MembreSearchCriteria( + organisationIds: [event.organisationId!], + query: event.recherche?.isNotEmpty == true ? event.recherche : null, + ), + page: event.page, + size: event.size, + ); + } else { + // SuperAdmin et autres rôles : accès global sans filtre org + result = await _getMembers( + page: event.page, + size: event.size, + recherche: event.recherche, + ); + } emit(MembresLoaded( membres: result.membres, @@ -76,16 +92,19 @@ class MembresBloc extends Bloc { currentPage: result.currentPage, pageSize: result.pageSize, totalPages: result.totalPages, + organisationId: event.organisationId, )); } 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 inattendue lors du chargement des membres: $e', + message: 'Erreur lors du chargement des membres. Veuillez réessayer.', error: e, )); } @@ -110,14 +129,16 @@ class MembresBloc extends Bloc { )); } } 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 du chargement du membre: $e', + message: 'Erreur lors du chargement du membre. Veuillez réessayer.', error: e, )); } @@ -135,6 +156,7 @@ class MembresBloc extends Bloc { emit(MembreCreated(membre)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; if (e.response?.statusCode == 400) { // Erreur de validation final errors = _extractValidationErrors(e.response?.data); @@ -151,8 +173,9 @@ class MembresBloc extends Bloc { )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(MembresError( - message: 'Erreur lors de la création du membre: $e', + message: 'Erreur lors de la création du membre. Veuillez réessayer.', error: e, )); } @@ -170,6 +193,7 @@ class MembresBloc extends Bloc { emit(MembreUpdated(membre)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; if (e.response?.statusCode == 400) { final errors = _extractValidationErrors(e.response?.data); emit(MembresValidationError( @@ -185,8 +209,9 @@ class MembresBloc extends Bloc { )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(MembresError( - message: 'Erreur lors de la mise à jour du membre: $e', + message: 'Erreur lors de la mise à jour du membre. Veuillez réessayer.', error: e, )); } @@ -204,14 +229,16 @@ class MembresBloc extends Bloc { emit(MembreDeleted(event.id)); } 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 suppression du membre: $e', + message: 'Erreur lors de la suppression du membre. Veuillez réessayer.', error: e, )); } @@ -229,14 +256,16 @@ class MembresBloc extends Bloc { emit(MembreActivated(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\'activation du membre: $e', + message: 'Erreur lors de l\'activation du membre. Veuillez réessayer.', error: e, )); } @@ -254,14 +283,16 @@ class MembresBloc extends Bloc { emit(MembreDeactivated(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 désactivation du membre: $e', + message: 'Erreur lors de la désactivation du membre. Veuillez réessayer.', error: e, )); } @@ -289,14 +320,16 @@ class MembresBloc extends Bloc { totalPages: result.totalPages, )); } 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 recherche de membres: $e', + message: 'Erreur lors de la recherche de membres. Veuillez réessayer.', error: e, )); } @@ -323,14 +356,16 @@ class MembresBloc extends Bloc { totalPages: result.totalPages, )); } 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 du chargement des membres actifs: $e', + message: 'Erreur lors du chargement des membres actifs. Veuillez réessayer.', error: e, )); } @@ -357,14 +392,16 @@ class MembresBloc extends Bloc { totalPages: result.totalPages, )); } 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 du chargement des membres du bureau: $e', + message: 'Erreur lors du chargement des membres du bureau. Veuillez réessayer.', error: e, )); } @@ -382,14 +419,16 @@ class MembresBloc extends Bloc { emit(MembresStatsLoaded(stats)); } 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 du chargement des statistiques: $e', + message: 'Erreur lors du chargement des statistiques. Veuillez réessayer.', error: e, )); } diff --git a/lib/features/members/bloc/membres_event.dart b/lib/features/members/bloc/membres_event.dart index fcbd2b2..a154782 100644 --- a/lib/features/members/bloc/membres_event.dart +++ b/lib/features/members/bloc/membres_event.dart @@ -19,16 +19,18 @@ class LoadMembres extends MembresEvent { final int size; final String? recherche; final bool refresh; + final String? organisationId; const LoadMembres({ this.page = 0, this.size = 20, this.recherche, this.refresh = false, + this.organisationId, }); @override - List get props => [page, size, recherche, refresh]; + List get props => [page, size, recherche, refresh, organisationId]; } /// Événement pour charger un membre par ID diff --git a/lib/features/members/bloc/membres_state.dart b/lib/features/members/bloc/membres_state.dart index 8bcc627..677e854 100644 --- a/lib/features/members/bloc/membres_state.dart +++ b/lib/features/members/bloc/membres_state.dart @@ -40,6 +40,7 @@ class MembresLoaded extends MembresState { final int pageSize; final int totalPages; final bool hasMore; + final String? organisationId; const MembresLoaded({ required this.membres, @@ -47,10 +48,19 @@ class MembresLoaded extends MembresState { this.currentPage = 0, this.pageSize = 20, required this.totalPages, + this.organisationId, }) : hasMore = currentPage < totalPages - 1; @override - List get props => [membres, totalElements, currentPage, pageSize, totalPages, hasMore]; + List get props => [ + membres, + totalElements, + currentPage, + pageSize, + totalPages, + hasMore, + organisationId, + ]; MembresLoaded copyWith({ List? membres, @@ -58,6 +68,7 @@ class MembresLoaded extends MembresState { int? currentPage, int? pageSize, int? totalPages, + String? organisationId, }) { return MembresLoaded( membres: membres ?? this.membres, @@ -65,6 +76,7 @@ class MembresLoaded extends MembresState { currentPage: currentPage ?? this.currentPage, pageSize: pageSize ?? this.pageSize, totalPages: totalPages ?? this.totalPages, + organisationId: organisationId ?? this.organisationId, ); } } diff --git a/lib/features/members/data/models/membre_complete_model.dart b/lib/features/members/data/models/membre_complete_model.dart index c1a7504..83f1e73 100644 --- a/lib/features/members/data/models/membre_complete_model.dart +++ b/lib/features/members/data/models/membre_complete_model.dart @@ -174,6 +174,10 @@ class MembreCompletModel extends Equatable { @JsonKey(name: 'dateVerificationIdentite') final DateTime? dateVerificationIdentite; + /// Mot de passe temporaire (retourné une seule fois à la création, null sinon) + @JsonKey(name: 'motDePasseTemporaire') + final String? motDePasseTemporaire; + const MembreCompletModel({ this.id, required this.nom, @@ -210,6 +214,7 @@ class MembreCompletModel extends Equatable { this.niveauVigilanceKyc, this.statutKyc, this.dateVerificationIdentite, + this.motDePasseTemporaire, }); /// Création depuis JSON @@ -256,6 +261,7 @@ class MembreCompletModel extends Equatable { NiveauVigilanceKyc? niveauVigilanceKyc, StatutKyc? statutKyc, DateTime? dateVerificationIdentite, + String? motDePasseTemporaire, }) { return MembreCompletModel( id: id ?? this.id, @@ -293,6 +299,7 @@ class MembreCompletModel extends Equatable { niveauVigilanceKyc: niveauVigilanceKyc ?? this.niveauVigilanceKyc, statutKyc: statutKyc ?? this.statutKyc, dateVerificationIdentite: dateVerificationIdentite ?? this.dateVerificationIdentite, + motDePasseTemporaire: motDePasseTemporaire ?? this.motDePasseTemporaire, ); } 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 1b80a27..fa7dd8d 100644 --- a/lib/features/members/data/models/membre_complete_model.g.dart +++ b/lib/features/members/data/models/membre_complete_model.g.dart @@ -60,6 +60,7 @@ MembreCompletModel _$MembreCompletModelFromJson(Map json) => dateVerificationIdentite: json['dateVerificationIdentite'] == null ? null : DateTime.parse(json['dateVerificationIdentite'] as String), + motDePasseTemporaire: json['motDePasseTemporaire'] as String?, ); Map _$MembreCompletModelToJson(MembreCompletModel instance) => @@ -101,6 +102,7 @@ Map _$MembreCompletModelToJson(MembreCompletModel instance) => 'statutKyc': _$StatutKycEnumMap[instance.statutKyc], 'dateVerificationIdentite': instance.dateVerificationIdentite?.toIso8601String(), + 'motDePasseTemporaire': instance.motDePasseTemporaire, }; const _$GenreEnumMap = { diff --git a/lib/features/members/data/repositories/membre_repository_impl.dart b/lib/features/members/data/repositories/membre_repository_impl.dart index 573ebce..5a7d867 100644 --- a/lib/features/members/data/repositories/membre_repository_impl.dart +++ b/lib/features/members/data/repositories/membre_repository_impl.dart @@ -49,10 +49,10 @@ class MembreRepositoryImpl implements IMembreRepository { ); return _parseMembreSearchResult(response, page, size, const MembreSearchCriteria()); - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des membres: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des membres: $e'); + rethrow; } } @@ -64,12 +64,29 @@ class MembreRepositoryImpl implements IMembreRepository { if (map.containsKey('organisationId') && map['organisationId'] != null && map['organisationId'] is! String) { map['organisationId'] = map['organisationId'].toString(); } + // Mapping statutCompte → statut avec normalisation des valeurs backend if (map.containsKey('statutCompte') && !map.containsKey('statut')) { - map['statut'] = map['statutCompte']; + final sc = (map['statutCompte'] as String? ?? '').toUpperCase(); + if (sc == 'ACTIF') { + map['statut'] = 'ACTIF'; + } else if (sc == 'INACTIF') { + map['statut'] = 'INACTIF'; + } else if (sc == 'SUSPENDU') { + map['statut'] = 'SUSPENDU'; + } else { + map['statut'] = 'EN_ATTENTE'; + } } if (map.containsKey('photoUrl') && !map.containsKey('photo')) { map['photo'] = map['photoUrl']; } + // roles (List) → role (premier rôle) + if (map.containsKey('roles') && !map.containsKey('role')) { + final roles = map['roles']; + if (roles is List && roles.isNotEmpty) { + map['role'] = roles.first?.toString(); + } + } if (map['id'] != null && map['id'] is! String) { map['id'] = map['id'].toString(); } @@ -155,12 +172,13 @@ class MembreRepositoryImpl implements IMembreRepository { throw Exception('Erreur lors de la récupération du membre: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { return null; } - throw Exception('Erreur réseau lors de la récupération du membre: ${e.message}'); + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération du membre: $e'); + rethrow; } } @@ -177,10 +195,10 @@ class MembreRepositoryImpl implements IMembreRepository { } else { throw Exception('Erreur lors de la création du membre: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la création du membre: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la création du membre: $e'); + rethrow; } } @@ -197,10 +215,10 @@ class MembreRepositoryImpl implements IMembreRepository { } else { throw Exception('Erreur lors de la mise à jour du membre: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la mise à jour du membre: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la mise à jour du membre: $e'); + rethrow; } } @@ -212,10 +230,10 @@ class MembreRepositoryImpl implements IMembreRepository { if (response.statusCode != 204 && response.statusCode != 200) { throw Exception('Erreur lors de la suppression du membre: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la suppression du membre: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la suppression du membre: $e'); + rethrow; } } @@ -229,10 +247,10 @@ class MembreRepositoryImpl implements IMembreRepository { } else { throw Exception('Erreur lors de l\'activation du membre: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de l\'activation du membre: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de l\'activation du membre: $e'); + rethrow; } } @@ -246,10 +264,10 @@ class MembreRepositoryImpl implements IMembreRepository { } else { throw Exception('Erreur lors de la désactivation du membre: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la désactivation du membre: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la désactivation du membre: $e'); + rethrow; } } @@ -272,10 +290,10 @@ class MembreRepositoryImpl implements IMembreRepository { ); return _parseMembreSearchResult(response, page, size, criteria); - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la recherche de membres: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la recherche de membres: $e'); + rethrow; } } @@ -315,10 +333,10 @@ class MembreRepositoryImpl implements IMembreRepository { } else { throw Exception('Erreur lors de la récupération des statistiques: ${response.statusCode}'); } - } on DioException catch (e) { - throw Exception('Erreur réseau lors de la récupération des statistiques: ${e.message}'); + } on DioException { + rethrow; } catch (e) { - throw Exception('Erreur inattendue lors de la récupération des statistiques: $e'); + rethrow; } } } diff --git a/lib/features/members/presentation/pages/advanced_search_page.dart b/lib/features/members/presentation/pages/advanced_search_page.dart index 279b251..e3f8ae8 100644 --- a/lib/features/members/presentation/pages/advanced_search_page.dart +++ b/lib/features/members/presentation/pages/advanced_search_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../../core/di/injection_container.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/models/membre_search_criteria.dart'; import '../../../../shared/models/membre_search_result.dart'; import '../../../organizations/domain/repositories/organization_repository.dart'; @@ -115,7 +116,7 @@ class _AdvancedSearchPageState extends State return Scaffold( appBar: AppBar( title: const Text('Recherche Avancée'), - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, elevation: 0, bottom: TabBar( @@ -178,7 +179,7 @@ class _AdvancedSearchPageState extends State children: [ Row( children: [ - Icon(Icons.flash_on, color: Theme.of(context).primaryColor), + const Icon(Icons.flash_on, color: AppColors.primaryGreen), const SizedBox(width: 8), Text( 'Recherche Rapide', @@ -233,7 +234,7 @@ class _AdvancedSearchPageState extends State children: [ Row( children: [ - Icon(Icons.tune, color: Theme.of(context).primaryColor), + const Icon(Icons.tune, color: AppColors.primaryGreen), const SizedBox(width: 8), Text( 'Critères Détaillés', @@ -324,7 +325,7 @@ class _AdvancedSearchPageState extends State children: [ Row( children: [ - Icon(Icons.filter_alt, color: Theme.of(context).primaryColor), + const Icon(Icons.filter_alt, color: AppColors.primaryGreen), const SizedBox(width: 8), Text( 'Filtres Avancés', @@ -499,7 +500,7 @@ class _AdvancedSearchPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error, size: 64, color: Colors.red), + const Icon(Icons.error, size: 64, color: AppColors.error), const SizedBox(height: 16), Text( 'Erreur de recherche', @@ -509,7 +510,7 @@ class _AdvancedSearchPageState extends State Text( _errorMessage!, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), + style: const TextStyle(color: AppColors.error), ), const SizedBox(height: 16), ElevatedButton( @@ -573,8 +574,8 @@ class _AdvancedSearchPageState extends State return ActionChip( label: Text(label), onPressed: onTap, - backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), - labelStyle: TextStyle(color: Theme.of(context).primaryColor), + backgroundColor: AppColors.primaryGreen.withOpacity(0.1), + labelStyle: const TextStyle(color: AppColors.primaryGreen), ); } @@ -620,7 +621,7 @@ class _AdvancedSearchPageState extends State ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Veuillez spécifier au moins un critère de recherche'), - backgroundColor: Colors.orange, + backgroundColor: AppColors.warning, ), ); return; @@ -649,7 +650,7 @@ class _AdvancedSearchPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(result.resultDescription), - backgroundColor: Colors.green, + backgroundColor: AppColors.success, ), ); } catch (e) { @@ -661,7 +662,7 @@ class _AdvancedSearchPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur de recherche: $e'), - backgroundColor: Colors.red, + backgroundColor: AppColors.error, ), ); } diff --git a/lib/features/members/presentation/pages/members_page.dart b/lib/features/members/presentation/pages/members_page.dart index 5085e4e..b9c74a6 100644 --- a/lib/features/members/presentation/pages/members_page.dart +++ b/lib/features/members/presentation/pages/members_page.dart @@ -1,5 +1,6 @@ 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'; @@ -174,13 +175,13 @@ class _MembersPageState extends State with TickerProviderStateMixin builder: (context, state) { if (state is! AuthAuthenticated) { return Container( - color: const Color(0xFFF8F9FA), + color: AppColors.lightBackground, child: const Center(child: CircularProgressIndicator()), ); } return Container( - color: const Color(0xFFF8F9FA), + color: AppColors.lightBackground, child: _buildMembersContent(state), ); }, @@ -196,19 +197,19 @@ class _MembersPageState extends State with TickerProviderStateMixin children: [ // Header avec titre et actions _buildMembersHeader(state), - const SizedBox(height: 16), + const SizedBox(height: 8), // Statistiques et métriques _buildMembersMetrics(), - const SizedBox(height: 16), + const SizedBox(height: 8), // Barre de recherche et filtres _buildSearchAndFilters(), - const SizedBox(height: 16), + const SizedBox(height: 8), // Onglets de catégories _buildCategoryTabs(), - const SizedBox(height: 16), + const SizedBox(height: 8), // Liste/Grille des membres _buildMembersDisplay(), @@ -265,8 +266,8 @@ class _MembersPageState extends State with TickerProviderStateMixin 'Métriques & Statistiques', style: TextStyle( fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - fontSize: 18, + color: AppColors.primaryGreen, + fontSize: 14, ), ), const SizedBox(height: 12), @@ -280,7 +281,7 @@ class _MembersPageState extends State with TickerProviderStateMixin totalMembers.toString(), '+$newThisMonth ce mois', Icons.people, - const Color(0xFF6C5CE7), + AppColors.primaryGreen, trend: newThisMonth > 0 ? 'up' : 'stable', ), ), @@ -291,7 +292,7 @@ class _MembersPageState extends State with TickerProviderStateMixin activeMembers.toString(), '${((activeMembers / totalMembers) * 100).toStringAsFixed(1)}%', Icons.check_circle, - const Color(0xFF00B894), + AppColors.success, trend: 'up', ), ), @@ -308,7 +309,7 @@ class _MembersPageState extends State with TickerProviderStateMixin avgContribution.toStringAsFixed(0), 'Contribution', Icons.trending_up, - const Color(0xFF0984E3), + AppColors.brandGreenLight, trend: 'up', ), ), @@ -319,7 +320,7 @@ class _MembersPageState extends State with TickerProviderStateMixin newThisMonth.toString(), 'Ce mois', Icons.new_releases, - const Color(0xFFF39C12), + AppColors.warning, trend: newThisMonth > 0 ? 'up' : 'stable', ), ), @@ -388,7 +389,7 @@ class _MembersPageState extends State with TickerProviderStateMixin style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), ), const SizedBox(height: 2), @@ -396,7 +397,7 @@ class _MembersPageState extends State with TickerProviderStateMixin subtitle, style: const TextStyle( fontSize: 10, - color: Color(0xFF9CA3AF), + color: AppColors.textSecondaryLight, ), ), ], @@ -424,7 +425,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ), child: Row( children: [ - const Icon(Icons.search, color: Color(0xFF6B7280)), + const Icon(Icons.search, color: AppColors.textSecondaryLight), const SizedBox(width: 12), Expanded( child: TextField( @@ -432,7 +433,7 @@ class _MembersPageState extends State with TickerProviderStateMixin decoration: const InputDecoration( hintText: 'Rechercher par nom, email, département...', border: InputBorder.none, - hintStyle: TextStyle(color: Color(0xFF9CA3AF)), + hintStyle: TextStyle(color: AppColors.textSecondaryLight), ), onChanged: (value) { setState(() { @@ -449,13 +450,13 @@ class _MembersPageState extends State with TickerProviderStateMixin _searchQuery = ''; }); }, - icon: const Icon(Icons.clear, color: Color(0xFF6B7280)), + icon: const Icon(Icons.clear, color: AppColors.textSecondaryLight), ), const SizedBox(width: 8), Container( height: 32, width: 1, - color: const Color(0xFFE5E7EB), + color: AppColors.lightBorder, ), const SizedBox(width: 8), IconButton( @@ -466,7 +467,7 @@ class _MembersPageState extends State with TickerProviderStateMixin }, icon: Icon( _showAdvancedFilters ? Icons.filter_list_off : Icons.filter_list, - color: _showAdvancedFilters ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + color: _showAdvancedFilters ? AppColors.primaryGreen : AppColors.textSecondaryLight, ), tooltip: 'Filtres avancés', ), @@ -478,7 +479,7 @@ class _MembersPageState extends State with TickerProviderStateMixin }, icon: Icon( _isGridView ? Icons.view_list : Icons.grid_view, - color: const Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), tooltip: _isGridView ? 'Vue liste' : 'Vue grille', ), @@ -526,14 +527,14 @@ class _MembersPageState extends State with TickerProviderStateMixin }); }, backgroundColor: Colors.white, - selectedColor: const Color(0xFF6C5CE7).withOpacity(0.1), - checkmarkColor: const Color(0xFF6C5CE7), + selectedColor: AppColors.primaryGreen.withOpacity(0.1), + checkmarkColor: AppColors.primaryGreen, labelStyle: TextStyle( - color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + color: isSelected ? AppColors.primaryGreen : AppColors.textSecondaryLight, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), side: BorderSide( - color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFFE5E7EB), + color: isSelected ? AppColors.primaryGreen : AppColors.lightBorder, ), ), ); @@ -549,7 +550,7 @@ class _MembersPageState extends State with TickerProviderStateMixin decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE5E7EB)), + border: Border.all(color: AppColors.lightBorder), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -558,7 +559,7 @@ class _MembersPageState extends State with TickerProviderStateMixin 'Filtres Avancés', style: TextStyle( fontWeight: FontWeight.w600, - color: Color(0xFF374151), + color: AppColors.textPrimaryLight, ), ), const SizedBox(height: 12), @@ -589,14 +590,14 @@ class _MembersPageState extends State with TickerProviderStateMixin }); }, backgroundColor: Colors.grey[50], - selectedColor: const Color(0xFF6C5CE7).withOpacity(0.1), - checkmarkColor: const Color(0xFF6C5CE7), + selectedColor: AppColors.primaryGreen.withOpacity(0.1), + checkmarkColor: AppColors.primaryGreen, labelStyle: TextStyle( - color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + color: isSelected ? AppColors.primaryGreen : AppColors.textSecondaryLight, fontSize: 12, ), side: BorderSide( - color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFFE5E7EB), + color: isSelected ? AppColors.primaryGreen : AppColors.lightBorder, ), ); }).toList(), @@ -616,14 +617,14 @@ class _MembersPageState extends State with TickerProviderStateMixin icon: const Icon(Icons.clear_all, size: 16), label: const Text('Réinitialiser'), style: TextButton.styleFrom( - foregroundColor: const Color(0xFF6B7280), + foregroundColor: AppColors.textSecondaryLight, ), ), const Spacer(), Text( '${_getFilteredMembers().length} résultat(s)', style: const TextStyle( - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, fontSize: 12, ), ), @@ -656,9 +657,9 @@ class _MembersPageState extends State with TickerProviderStateMixin Tab(text: 'Équipes', icon: Icon(Icons.groups, size: 18)), Tab(text: 'Analytics', icon: Icon(Icons.analytics, size: 18)), ], - labelColor: const Color(0xFF6C5CE7), - unselectedLabelColor: const Color(0xFF6B7280), - indicatorColor: const Color(0xFF6C5CE7), + labelColor: AppColors.primaryGreen, + unselectedLabelColor: AppColors.textSecondaryLight, + indicatorColor: AppColors.primaryGreen, indicatorWeight: 3, labelStyle: const TextStyle( fontSize: 12, @@ -804,7 +805,7 @@ class _MembersPageState extends State with TickerProviderStateMixin style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 16, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), ), @@ -829,7 +830,7 @@ class _MembersPageState extends State with TickerProviderStateMixin Text( member['email'], style: const TextStyle( - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, fontSize: 14, ), ), @@ -844,24 +845,24 @@ class _MembersPageState extends State with TickerProviderStateMixin const SizedBox(width: 4), Text( member['department'], - style: TextStyle( + style: const TextStyle( fontSize: 12, - color: Colors.grey[600], + color: AppColors.textSecondaryLight, ), ), const SizedBox(width: 12), - Icon( + const Icon( Icons.location_on, size: 12, - color: Colors.grey[500], + color: AppColors.textSecondaryLight, ), const SizedBox(width: 4), Expanded( child: Text( member['location'], - style: TextStyle( + style: const TextStyle( fontSize: 12, - color: Colors.grey[600], + color: AppColors.textSecondaryLight, ), overflow: TextOverflow.ellipsis, ), @@ -903,7 +904,7 @@ class _MembersPageState extends State with TickerProviderStateMixin 'Rejoint ${_formatDate(joinDate)}', style: const TextStyle( fontSize: 10, - color: Color(0xFF9CA3AF), + color: AppColors.textSecondaryLight, ), ), const Spacer(), @@ -911,7 +912,7 @@ class _MembersPageState extends State with TickerProviderStateMixin 'Actif ${_formatRelativeTime(lastActivity)}', style: const TextStyle( fontSize: 10, - color: Color(0xFF9CA3AF), + color: AppColors.textSecondaryLight, ), ), ], @@ -974,7 +975,7 @@ class _MembersPageState extends State with TickerProviderStateMixin child: const Icon( Icons.more_vert, size: 16, - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), ), ), @@ -1040,15 +1041,15 @@ class _MembersPageState extends State with TickerProviderStateMixin Color _getStatusColor(String status) { switch (status) { case 'Actif': - return const Color(0xFF10B981); + return AppColors.success; case 'Inactif': - return const Color(0xFF6B7280); + return AppColors.textSecondaryLight; case 'Suspendu': - return const Color(0xFFDC2626); + return AppColors.error; case 'En attente': - return const Color(0xFFF59E0B); + return AppColors.warning; default: - return const Color(0xFF6B7280); + return AppColors.textSecondaryLight; } } @@ -1056,30 +1057,30 @@ class _MembersPageState extends State with TickerProviderStateMixin Color _getRoleColor(String role) { switch (role) { case 'Super Administrateur': - return const Color(0xFF7C3AED); + return AppColors.brandGreen; case 'Administrateur Org': - return const Color(0xFF6366F1); + return AppColors.primaryGreen; case 'Gestionnaire RH': - return const Color(0xFF0EA5E9); + return AppColors.info; case 'Modérateur': - return const Color(0xFF059669); + return AppColors.brandGreenLight; case 'Membre Actif': - return const Color(0xFF6C5CE7); + return AppColors.primaryGreen; case 'Consultant': - return const Color(0xFFF59E0B); + return AppColors.warning; case 'Membre Simple': - return const Color(0xFF6B7280); + return AppColors.textSecondaryLight; default: - return const Color(0xFF6B7280); + return AppColors.textSecondaryLight; } } /// Obtient la couleur selon le score de contribution Color _getScoreColor(int score) { - if (score >= 90) return const Color(0xFF10B981); - if (score >= 70) return const Color(0xFF0EA5E9); - if (score >= 50) return const Color(0xFFF59E0B); - return const Color(0xFFDC2626); + if (score >= 90) return AppColors.success; + if (score >= 70) return AppColors.brandGreenLight; + if (score >= 50) return AppColors.warning; + return AppColors.error; } /// Formate une date @@ -1174,7 +1175,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Messagerie groupée à venir. Utilisez l\'action « Message » sur un membre.'), - backgroundColor: Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, ), ); }, @@ -1190,7 +1191,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Export des membres en cours...'), - backgroundColor: Color(0xFF10B981), + backgroundColor: AppColors.success, ), ); } @@ -1234,7 +1235,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Message à ${member['name']} à implémenter'), - backgroundColor: const Color(0xFF0EA5E9), + backgroundColor: AppColors.info, ), ); } @@ -1257,7 +1258,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${member['name']} supprimé'), - backgroundColor: const Color(0xFFDC2626), + backgroundColor: AppColors.error, ), ); }, @@ -1413,13 +1414,13 @@ class _MembersPageState extends State with TickerProviderStateMixin Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: const Color(0xFF6C5CE7).withOpacity(0.1), + color: AppColors.primaryGreen.withOpacity(0.1), shape: BoxShape.circle, ), child: const Icon( Icons.people_outline, size: 48, - color: Color(0xFF6C5CE7), + color: AppColors.primaryGreen, ), ), const SizedBox(height: 16), @@ -1428,7 +1429,7 @@ class _MembersPageState extends State with TickerProviderStateMixin style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: Color(0xFF374151), + color: AppColors.textPrimaryLight, ), ), const SizedBox(height: 8), @@ -1437,7 +1438,7 @@ class _MembersPageState extends State with TickerProviderStateMixin ? 'Aucun membre ne correspond à votre recherche' : 'Aucun membre ne correspond aux filtres sélectionnés', style: const TextStyle( - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), textAlign: TextAlign.center, ), @@ -1454,7 +1455,7 @@ class _MembersPageState extends State with TickerProviderStateMixin icon: const Icon(Icons.refresh), label: const Text('Réinitialiser les filtres'), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), ), @@ -1667,7 +1668,7 @@ class _MembersPageState extends State with TickerProviderStateMixin width: 24, height: 24, decoration: BoxDecoration( - color: index < 3 ? const Color(0xFFF59E0B) : const Color(0xFF6B7280), + color: index < 3 ? AppColors.warning : AppColors.textSecondaryLight, borderRadius: BorderRadius.circular(12), ), child: Center( @@ -1707,7 +1708,7 @@ class _MembersPageState extends State with TickerProviderStateMixin member['role'], style: const TextStyle( fontSize: 12, - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), ), ], @@ -1890,7 +1891,7 @@ class _MembersPageState extends State with TickerProviderStateMixin icon: const Icon(Icons.edit), label: const Text('Modifier'), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), ), @@ -1929,7 +1930,7 @@ class _MembersPageState extends State with TickerProviderStateMixin style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: Color(0xFF374151), + color: AppColors.textPrimaryLight, ), ), const SizedBox(height: 12), @@ -1948,7 +1949,7 @@ class _MembersPageState extends State with TickerProviderStateMixin Icon( icon, size: 20, - color: const Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), const SizedBox(width: 12), Expanded( @@ -1959,7 +1960,7 @@ class _MembersPageState extends State with TickerProviderStateMixin label, style: const TextStyle( fontSize: 12, - color: Color(0xFF6B7280), + color: AppColors.textSecondaryLight, ), ), Text( @@ -1967,7 +1968,7 @@ class _MembersPageState extends State with TickerProviderStateMixin style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: Color(0xFF374151), + 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 cc6a2c3..eff3c6e 100644 --- a/lib/features/members/presentation/pages/members_page_connected.dart +++ b/lib/features/members/presentation/pages/members_page_connected.dart @@ -79,7 +79,7 @@ class _MembersPageWithDataAndPaginationState extends State m['status'] == 'En attente').length; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: UnionFlowColors.surface, border: Border( @@ -100,10 +100,10 @@ class _MembersPageWithDataAndPaginationState extends State setState(() => _filterStatus = label), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.surface, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), border: Border.all(color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.border, width: 1), ), child: Text( @@ -225,9 +225,9 @@ class _MembersPageWithDataAndPaginationState extends State widget.onRefresh(), color: UnionFlowColors.unionGreen, child: ListView.separated( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), + separatorBuilder: (_, __) => const SizedBox(height: 6), itemBuilder: (context, index) => _buildMemberCard(filtered[index]), ), ); @@ -237,23 +237,22 @@ class _MembersPageWithDataAndPaginationState extends State _showMemberDetails(member), child: Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), border: Border.all(color: UnionFlowColors.border.withOpacity(0.3), width: 1), ), child: Row( children: [ Container( - width: 48, - height: 48, + width: 32, + height: 32, decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle), alignment: Alignment.center, child: Text( member['initiales'] ?? '??', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 18), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13), ), ), const SizedBox(width: 12), @@ -322,14 +321,14 @@ class _MembersPageWithDataAndPaginationState extends State Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), decoration: const BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 80, - height: 80, + 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: 32), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22), ), ), - const SizedBox(height: 16), + const SizedBox(height: 10), Text( member['name'] ?? '', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), ), const SizedBox(height: 4), Text( member['role'] ?? '', style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), ), - const SizedBox(height: 20), + 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: 24), + const SizedBox(height: 12), ], ), ), diff --git a/lib/features/members/presentation/pages/members_page_wrapper.dart b/lib/features/members/presentation/pages/members_page_wrapper.dart index 4238915..b33010f 100644 --- a/lib/features/members/presentation/pages/members_page_wrapper.dart +++ b/lib/features/members/presentation/pages/members_page_wrapper.dart @@ -22,21 +22,23 @@ final _getIt = GetIt.instance; /// Wrapper qui fournit le BLoC à la page des membres class MembersPageWrapper extends StatelessWidget { - const MembersPageWrapper({super.key}); + final String? organisationId; + + const MembersPageWrapper({super.key, this.organisationId}); @override Widget build(BuildContext context) { AppLogger.info('MembersPageWrapper: Création du BlocProvider'); - + return BlocProvider( create: (context) { AppLogger.info('MembresPageWrapper: Initialisation du MembresBloc'); final bloc = _getIt(); // Charger les membres au démarrage - bloc.add(const LoadMembres()); + bloc.add(LoadMembres(organisationId: organisationId)); return bloc; }, - child: const MembersPageConnected(), + child: MembersPageConnected(organisationId: organisationId), ); } } @@ -45,15 +47,59 @@ class MembersPageWrapper extends StatelessWidget { /// /// Cette page gère les états du BLoC et affiche l'UI appropriée class MembersPageConnected extends StatelessWidget { - const MembersPageConnected({super.key}); + final String? organisationId; + + const MembersPageConnected({super.key, this.organisationId}); @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - // Après création : recharger la liste + // Après création : afficher le mot de passe temporaire si disponible, puis recharger if (state is MembreCreated) { - context.read().add(const LoadMembres(refresh: true)); + 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 @@ -67,7 +113,7 @@ class MembersPageConnected extends StatelessWidget { label: 'Réessayer', textColor: Colors.white, onPressed: () { - context.read().add(const LoadMembres()); + context.read().add(LoadMembres(organisationId: organisationId)); }, ), ), @@ -134,19 +180,23 @@ class MembersPageConnected extends StatelessWidget { totalPages: state.totalPages, onPageChanged: (newPage, recherche) { AppLogger.userAction('Load page', data: {'page': newPage}); - context.read().add(LoadMembres(page: newPage, recherche: recherche)); + context.read().add(LoadMembres(page: newPage, recherche: recherche, organisationId: organisationId)); }, onRefresh: () { AppLogger.userAction('Refresh membres'); - context.read().add(const LoadMembres(refresh: true)); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); }, onSearch: (query) { - context.read().add(LoadMembres(page: 0, recherche: query)); + context.read().add(LoadMembres(page: 0, recherche: query, organisationId: organisationId)); }, onAddMember: () async { + final bloc = context.read(); await showDialog( context: context, - builder: (_) => const AddMemberDialog(), + builder: (_) => BlocProvider.value( + value: bloc, + child: const AddMemberDialog(), + ), ); }, ); @@ -160,7 +210,7 @@ class MembersPageConnected extends StatelessWidget { child: NetworkErrorWidget( onRetry: () { AppLogger.userAction('Retry load membres after network error'); - context.read().add(const LoadMembres()); + context.read().add(LoadMembres(organisationId: organisationId)); }, ), ); @@ -175,7 +225,7 @@ class MembersPageConnected extends StatelessWidget { message: state.message, onRetry: () { AppLogger.userAction('Retry load membres after error'); - context.read().add(const LoadMembres()); + context.read().add(LoadMembres(organisationId: organisationId)); }, ), ); diff --git a/lib/features/notifications/data/repositories/notification_feed_repository.dart b/lib/features/notifications/data/repositories/notification_feed_repository.dart index 948fb2b..4039ecc 100644 --- a/lib/features/notifications/data/repositories/notification_feed_repository.dart +++ b/lib/features/notifications/data/repositories/notification_feed_repository.dart @@ -32,6 +32,7 @@ class NotificationFeedRepository { .map((json) => _itemFromJson(json as Map)) .toList(); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return []; rethrow; } diff --git a/lib/features/notifications/data/repositories/notification_repository.dart b/lib/features/notifications/data/repositories/notification_repository.dart index f60651f..a7f2fc2 100644 --- a/lib/features/notifications/data/repositories/notification_repository.dart +++ b/lib/features/notifications/data/repositories/notification_repository.dart @@ -35,6 +35,7 @@ class NotificationRepositoryImpl implements NotificationRepository { } return []; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return []; rethrow; } @@ -51,6 +52,7 @@ class NotificationRepositoryImpl implements NotificationRepository { } return []; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return []; rethrow; } @@ -69,6 +71,7 @@ class NotificationRepositoryImpl implements NotificationRepository { } return []; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return []; rethrow; } @@ -87,6 +90,7 @@ class NotificationRepositoryImpl implements NotificationRepository { } return []; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return []; rethrow; } @@ -101,6 +105,7 @@ class NotificationRepositoryImpl implements NotificationRepository { } return null; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return null; rethrow; } diff --git a/lib/features/notifications/presentation/bloc/notification_bloc.dart b/lib/features/notifications/presentation/bloc/notification_bloc.dart index afc6ad8..0ec1844 100644 --- a/lib/features/notifications/presentation/bloc/notification_bloc.dart +++ b/lib/features/notifications/presentation/bloc/notification_bloc.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; @@ -21,6 +22,7 @@ class NotificationBloc extends Bloc { final items = await _repository.getNotifications(); emit(NotificationLoaded(items: items)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NotificationBloc: chargement notifications échoué', error: e, stackTrace: st); emit(NotificationError('Erreur de chargement: $e')); } @@ -46,6 +48,7 @@ class NotificationBloc extends Bloc { }).toList(); emit(NotificationLoaded(items: updatedItems)); } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NotificationBloc: marquer comme lu échoué', error: e, stackTrace: st); emit(NotificationError('Impossible de marquer comme lu')); } diff --git a/lib/features/notifications/presentation/bloc/notifications_bloc.dart b/lib/features/notifications/presentation/bloc/notifications_bloc.dart index 332dcd2..cea54ca 100644 --- a/lib/features/notifications/presentation/bloc/notifications_bloc.dart +++ b/lib/features/notifications/presentation/bloc/notifications_bloc.dart @@ -33,8 +33,10 @@ class NotificationsBloc extends Bloc { final nonLues = notifications.where((n) => !n.estLue).length; emit(NotificationsLoaded(notifications: notifications, nonLuesCount: nonLues)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; emit(NotificationsError(_networkError(e))); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(NotificationsError('Erreur lors du chargement : $e')); } } @@ -70,6 +72,7 @@ class NotificationsBloc extends Bloc { emit(NotificationMarkedAsRead(notifications: updated, nonLuesCount: nonLues)); } } catch (e, st) { + if (e is DioException && e.type == DioExceptionType.cancel) return; AppLogger.error('NotificationsBloc: marquer comme lu échoué', error: e, stackTrace: st); emit(NotificationsError('Impossible de marquer comme lu')); } diff --git a/lib/features/notifications/presentation/pages/notifications_page.dart b/lib/features/notifications/presentation/pages/notifications_page.dart index 4e6e038..6e97b61 100644 --- a/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/lib/features/notifications/presentation/pages/notifications_page.dart @@ -77,7 +77,7 @@ class _NotificationsPageState extends State }, builder: (context, state) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: AppColors.lightBackground, body: Column( children: [ _buildHeader(), @@ -101,8 +101,8 @@ class _NotificationsPageState extends State /// Header harmonisé avec le design system Widget _buildHeader() { return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( gradient: LinearGradient( colors: [AppColors.brandGreen, AppColors.primaryGreen], @@ -169,7 +169,7 @@ class _NotificationsPageState extends State /// Barre d'onglets Widget _buildTabBar() { return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), + margin: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: AppColors.lightSurface, borderRadius: BorderRadius.circular(8), @@ -196,8 +196,8 @@ class _NotificationsPageState extends State Widget _buildNotificationsTab() { return Column( children: [ - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Filtres et options _buildFiltersSection(), @@ -212,7 +212,7 @@ class _NotificationsPageState extends State /// Section filtres Widget _buildFiltersSection() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -436,7 +436,7 @@ class _NotificationsPageState extends State padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), // Notifications push _buildPreferenceSection( @@ -661,7 +661,7 @@ class _NotificationsPageState extends State case 'Membres': return AppColors.primaryGreen; case 'Événements': - return const Color(0xFF00B894); + return AppColors.success; case 'Organisations': return AppColors.primaryGreen; case 'Système': @@ -797,7 +797,7 @@ class _NotificationsPageState extends State _tabController.animateTo(1); // Aller à l'onglet Préférences }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Voir les préférences'), @@ -858,13 +858,13 @@ class _NotificationsPageState extends State width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFE17055).withOpacity(0.1), + color: AppColors.warning.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Text( 'Action disponible : ${notification['actionText']}', style: const TextStyle( - color: Color(0xFFE17055), + color: AppColors.warning, fontWeight: FontWeight.w600, ), ), @@ -883,7 +883,7 @@ class _NotificationsPageState extends State _showSuccessSnackBar('Action "${notification['actionText']}" exécutée'); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE17055), + backgroundColor: AppColors.warning, foregroundColor: Colors.white, ), child: Text(notification['actionText']), @@ -908,7 +908,7 @@ class _NotificationsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -919,7 +919,7 @@ class _NotificationsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFFE74C3C), + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); diff --git a/lib/features/onboarding/bloc/onboarding_bloc.dart b/lib/features/onboarding/bloc/onboarding_bloc.dart new file mode 100644 index 0000000..ff2896d --- /dev/null +++ b/lib/features/onboarding/bloc/onboarding_bloc.dart @@ -0,0 +1,292 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:injectable/injectable.dart'; +import '../data/datasources/souscription_datasource.dart'; +import '../data/models/formule_model.dart'; +import '../data/models/souscription_status_model.dart'; +import '../../../../core/utils/logger.dart'; + +// ─────────────────────────────────────────────────────────────── Events ────── + +abstract class OnboardingEvent extends Equatable { + const OnboardingEvent(); + @override + List get props => []; +} + +/// Démarre le workflow (charge les formules + état courant si souscription existante) +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}); + @override + List get props => [initialState, existingSouscriptionId]; +} + +/// L'utilisateur a sélectionné une formule et une plage +class OnboardingFormuleSelected extends OnboardingEvent { + final String codeFormule; + final String plage; + const OnboardingFormuleSelected({required this.codeFormule, required this.plage}); + @override + List get props => [codeFormule, plage]; +} + +/// L'utilisateur a sélectionné la période et le type d'organisation +class OnboardingPeriodeSelected extends OnboardingEvent { + final String typePeriode; + final String typeOrganisation; + final String organisationId; + const OnboardingPeriodeSelected({ + required this.typePeriode, + required this.typeOrganisation, + required this.organisationId, + }); + @override + List get props => [typePeriode, typeOrganisation, organisationId]; +} + +/// Confirme la demande et crée la souscription en base +class OnboardingDemandeConfirmee extends OnboardingEvent { + const OnboardingDemandeConfirmee(); +} + +/// Initie le paiement Wave +class OnboardingPaiementInitie extends OnboardingEvent { + const OnboardingPaiementInitie(); +} + +/// L'utilisateur est revenu dans l'app après le paiement Wave +class OnboardingRetourDepuisWave extends OnboardingEvent { + const OnboardingRetourDepuisWave(); +} + +// ─────────────────────────────────────────────────────────────── States ────── + +abstract class OnboardingState extends Equatable { + const OnboardingState(); + @override + List get props => []; +} + +class OnboardingInitial extends OnboardingState {} +class OnboardingLoading extends OnboardingState {} + +class OnboardingError extends OnboardingState { + final String message; + const OnboardingError(this.message); + @override + List get props => [message]; +} + +/// Étape 1 : choix formule + plage (affiche la grille des formules) +class OnboardingStepFormule extends OnboardingState { + final List formules; + const OnboardingStepFormule(this.formules); + @override + List get props => [formules]; +} + +/// Étape 2 : choix période + type organisation (formule/plage déjà sélectionnés) +class OnboardingStepPeriode extends OnboardingState { + final String codeFormule; + final String plage; + final List formules; + const OnboardingStepPeriode({ + required this.codeFormule, + required this.plage, + required this.formules, + }); + @override + List get props => [codeFormule, plage, formules]; +} + +/// Étape 3 : récapitulatif avec montant calculé (avant paiement) +class OnboardingStepSummary extends OnboardingState { + final SouscriptionStatusModel souscription; + const OnboardingStepSummary(this.souscription); + @override + List get props => [souscription]; +} + +/// Étape 4 : paiement Wave — URL à ouvrir dans le navigateur +class OnboardingStepPaiement extends OnboardingState { + final SouscriptionStatusModel souscription; + final String waveLaunchUrl; + const OnboardingStepPaiement({required this.souscription, required this.waveLaunchUrl}); + @override + List get props => [souscription, waveLaunchUrl]; +} + +/// Étape 5 : en attente de validation SuperAdmin +class OnboardingStepAttente extends OnboardingState { + final SouscriptionStatusModel? souscription; + const OnboardingStepAttente({this.souscription}); + @override + List get props => [souscription]; +} + +/// Souscription rejetée par le SuperAdmin +class OnboardingRejected extends OnboardingState { + final String? commentaire; + const OnboardingRejected({this.commentaire}); + @override + List get props => [commentaire]; +} + +// ─────────────────────────────────────────────────────────────── BLoC ──────── + +@injectable +class OnboardingBloc extends Bloc { + final SouscriptionDatasource _datasource; + + // Données accumulées au fil du wizard + List _formules = []; + String? _codeFormule; + String? _plage; + String? _typePeriode; + String? _typeOrganisation; + String? _organisationId; + SouscriptionStatusModel? _souscription; + + OnboardingBloc(this._datasource) : super(OnboardingInitial()) { + on(_onStarted); + on(_onFormuleSelected); + on(_onPeriodeSelected); + on(_onDemandeConfirmee); + on(_onPaiementInitie); + on(_onRetourDepuisWave); + } + + Future _onStarted(OnboardingStarted event, Emitter emit) async { + emit(OnboardingLoading()); + try { + _formules = await _datasource.getFormules(); + + switch (event.initialState) { + case 'NO_SUBSCRIPTION': + emit(OnboardingStepFormule(_formules)); + + case 'AWAITING_PAYMENT': + final sosc = await _datasource.getMaSouscription(); + if (sosc != null) { + _souscription = sosc; + emit(OnboardingStepSummary(sosc)); + } else { + emit(OnboardingStepFormule(_formules)); + } + + case 'PAYMENT_INITIATED': + final sosc = await _datasource.getMaSouscription(); + if (sosc != null && sosc.waveLaunchUrl != null) { + _souscription = sosc; + emit(OnboardingStepPaiement( + souscription: sosc, + waveLaunchUrl: sosc.waveLaunchUrl!, + )); + } else { + emit(OnboardingStepAttente(souscription: sosc)); + } + + case 'AWAITING_VALIDATION': + final sosc = await _datasource.getMaSouscription(); + _souscription = sosc; + emit(OnboardingStepAttente(souscription: sosc)); + + case 'REJECTED': + final sosc = await _datasource.getMaSouscription(); + emit(OnboardingRejected(commentaire: sosc?.statutValidation)); + + default: + emit(OnboardingStepFormule(_formules)); + } + } catch (e) { + AppLogger.error('OnboardingBloc._onStarted: $e'); + emit(const OnboardingError('Impossible de charger les formules. Vérifiez votre connexion.')); + } + } + + void _onFormuleSelected(OnboardingFormuleSelected event, Emitter emit) { + _codeFormule = event.codeFormule; + _plage = event.plage; + emit(OnboardingStepPeriode( + codeFormule: event.codeFormule, + plage: event.plage, + formules: _formules, + )); + } + + void _onPeriodeSelected(OnboardingPeriodeSelected event, Emitter emit) { + _typePeriode = event.typePeriode; + _typeOrganisation = event.typeOrganisation; + _organisationId = event.organisationId; + } + + Future _onDemandeConfirmee( + OnboardingDemandeConfirmee event, Emitter emit) async { + if (_codeFormule == null || _plage == null || _typePeriode == null || + _typeOrganisation == null || _organisationId == null) { + emit(const OnboardingError('Données manquantes. Recommencez depuis le début.')); + return; + } + emit(OnboardingLoading()); + try { + final sosc = await _datasource.creerDemande( + typeFormule: _codeFormule!, + plageMembres: _plage!, + typePeriode: _typePeriode!, + typeOrganisation: _typeOrganisation!, + organisationId: _organisationId!, + ); + if (sosc != null) { + _souscription = sosc; + emit(OnboardingStepSummary(sosc)); + } else { + emit(const OnboardingError('Erreur lors de la création de la demande.')); + } + } catch (e) { + emit(OnboardingError('Erreur: $e')); + } + } + + Future _onPaiementInitie( + OnboardingPaiementInitie event, Emitter emit) async { + final souscId = _souscription?.souscriptionId; + if (souscId == null) { + emit(const OnboardingError('Souscription introuvable.')); + return; + } + emit(OnboardingLoading()); + try { + final updated = await _datasource.initierPaiement(souscId); + if (updated?.waveLaunchUrl != null) { + _souscription = updated; + emit(OnboardingStepPaiement( + souscription: updated!, + waveLaunchUrl: updated.waveLaunchUrl!, + )); + } else { + emit(const OnboardingError('Impossible d\'initier le paiement Wave.')); + } + } catch (e) { + emit(OnboardingError('Erreur paiement: $e')); + } + } + + Future _onRetourDepuisWave( + OnboardingRetourDepuisWave event, Emitter emit) async { + emit(OnboardingLoading()); + try { + final souscId = _souscription?.souscriptionId; + if (souscId != null) { + await _datasource.confirmerPaiement(souscId); + } + final sosc = await _datasource.getMaSouscription(); + _souscription = sosc; + emit(OnboardingStepAttente(souscription: sosc)); + } catch (e) { + // En cas d'erreur, on affiche quand même l'écran d'attente + emit(OnboardingStepAttente(souscription: _souscription)); + } + } +} diff --git a/lib/features/onboarding/data/datasources/souscription_datasource.dart b/lib/features/onboarding/data/datasources/souscription_datasource.dart new file mode 100644 index 0000000..e240e38 --- /dev/null +++ b/lib/features/onboarding/data/datasources/souscription_datasource.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../../core/config/environment.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../features/authentication/data/datasources/keycloak_auth_service.dart'; +import '../models/formule_model.dart'; +import '../models/souscription_status_model.dart'; + +@lazySingleton +class SouscriptionDatasource { + final KeycloakAuthService _authService; + final Dio _dio = Dio(); + + SouscriptionDatasource(this._authService); + + Future _authOptions() async { + final token = await _authService.getValidToken(); + return Options( + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (s) => s != null && s < 500, + ); + } + + String get _base => AppConfig.apiBaseUrl; + + /// Liste toutes les formules disponibles (public) + Future> getFormules() async { + try { + final opts = await _authOptions(); + final response = await _dio.get('$_base/api/souscriptions/formules', options: opts); + if (response.statusCode == 200 && response.data is List) { + return (response.data as List) + .map((e) => FormuleModel.fromJson(e as Map)) + .toList(); + } + } catch (e) { + AppLogger.error('SouscriptionDatasource.getFormules: $e'); + } + return []; + } + + /// Récupère la souscription courante de l'OrgAdmin connecté + Future getMaSouscription() async { + try { + final opts = await _authOptions(); + final response = await _dio.get('$_base/api/souscriptions/ma-souscription', options: opts); + if (response.statusCode == 200 && response.data is Map) { + return SouscriptionStatusModel.fromJson(response.data as Map); + } + } catch (e) { + AppLogger.error('SouscriptionDatasource.getMaSouscription: $e'); + } + return null; + } + + /// Crée une demande de souscription + Future creerDemande({ + required String typeFormule, + required String plageMembres, + required String typePeriode, + required String typeOrganisation, + required String organisationId, + }) async { + try { + final opts = await _authOptions(); + final response = await _dio.post( + '$_base/api/souscriptions/demande', + data: { + 'typeFormule': typeFormule, + 'plageMembres': plageMembres, + 'typePeriode': typePeriode, + 'typeOrganisation': typeOrganisation, + 'organisationId': organisationId, + }, + 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}'); + } catch (e) { + AppLogger.error('SouscriptionDatasource.creerDemande: $e'); + } + return null; + } + + /// Initie le paiement Wave pour une souscription existante + Future initierPaiement(String souscriptionId) async { + try { + final opts = await _authOptions(); + final response = await _dio.post( + '$_base/api/souscriptions/$souscriptionId/initier-paiement', + options: opts, + ); + if (response.statusCode == 200 && response.data is Map) { + return SouscriptionStatusModel.fromJson(response.data as Map); + } + } catch (e) { + AppLogger.error('SouscriptionDatasource.initierPaiement: $e'); + } + return null; + } + + /// Confirme le paiement Wave après retour du deep link + Future confirmerPaiement(String souscriptionId) async { + try { + final opts = await _authOptions(); + final response = await _dio.post( + '$_base/api/souscriptions/$souscriptionId/confirmer-paiement', + options: opts, + ); + return response.statusCode == 200; + } catch (e) { + AppLogger.error('SouscriptionDatasource.confirmerPaiement: $e'); + } + return false; + } +} diff --git a/lib/features/onboarding/data/models/auth_status_model.dart b/lib/features/onboarding/data/models/auth_status_model.dart new file mode 100644 index 0000000..bb02cbb --- /dev/null +++ b/lib/features/onboarding/data/models/auth_status_model.dart @@ -0,0 +1,37 @@ +/// Réponse enrichie de /api/membres/mon-statut +class AuthStatusModel { + final String statutCompte; + + /// État du workflow d'onboarding — non null si statutCompte == EN_ATTENTE_VALIDATION + final String onboardingState; + final String? souscriptionId; + final String? waveSessionId; + + const AuthStatusModel({ + required this.statutCompte, + this.onboardingState = 'NO_SUBSCRIPTION', + this.souscriptionId, + this.waveSessionId, + }); + + bool get isActive => statutCompte == 'ACTIF'; + bool get isPendingOnboarding => statutCompte == 'EN_ATTENTE_VALIDATION'; + bool get isBlocked => + statutCompte == 'SUSPENDU' || statutCompte == 'DESACTIVE'; + + factory AuthStatusModel.fromJson(Map json) { + return AuthStatusModel( + statutCompte: (json['statutCompte'] as String?) ?? 'ACTIF', + onboardingState: (json['onboardingState'] as String?) ?? 'NO_SUBSCRIPTION', + souscriptionId: json['souscriptionId'] as String?, + waveSessionId: json['waveSessionId'] as String?, + ); + } + + factory AuthStatusModel.active() => + const AuthStatusModel(statutCompte: 'ACTIF', onboardingState: 'VALIDATED'); + + @override + String toString() => + 'AuthStatusModel($statutCompte, onboarding=$onboardingState, sous=$souscriptionId)'; +} diff --git a/lib/features/onboarding/data/models/formule_model.dart b/lib/features/onboarding/data/models/formule_model.dart new file mode 100644 index 0000000..7e227a6 --- /dev/null +++ b/lib/features/onboarding/data/models/formule_model.dart @@ -0,0 +1,43 @@ +/// Formule d'abonnement retournée par /api/souscriptions/formules +class FormuleModel { + final String code; // BASIC | STANDARD | PREMIUM + final String libelle; + final String? description; + final String plage; // PETITE | MOYENNE | GRANDE | TRES_GRANDE + final String plageLibelle; + final int minMembres; + final int maxMembres; // -1 = illimité + final double prixMensuel; + final double prixAnnuel; + final int ordreAffichage; + + const FormuleModel({ + required this.code, + required this.libelle, + this.description, + required this.plage, + required this.plageLibelle, + required this.minMembres, + required this.maxMembres, + required this.prixMensuel, + required this.prixAnnuel, + required this.ordreAffichage, + }); + + factory FormuleModel.fromJson(Map json) { + return FormuleModel( + code: json['code'] as String, + libelle: json['libelle'] as String, + description: json['description'] as String?, + plage: json['plage'] as String, + plageLibelle: (json['plageLibelle'] as String?) ?? '', + minMembres: (json['minMembres'] as num?)?.toInt() ?? 0, + maxMembres: (json['maxMembres'] as num?)?.toInt() ?? -1, + prixMensuel: (json['prixMensuel'] as num?)?.toDouble() ?? 0, + prixAnnuel: (json['prixAnnuel'] as num?)?.toDouble() ?? 0, + ordreAffichage: (json['ordreAffichage'] as num?)?.toInt() ?? 0, + ); + } + + String get maxMembresLabel => maxMembres == -1 ? '∞' : '$maxMembres'; +} diff --git a/lib/features/onboarding/data/models/souscription_status_model.dart b/lib/features/onboarding/data/models/souscription_status_model.dart new file mode 100644 index 0000000..e786021 --- /dev/null +++ b/lib/features/onboarding/data/models/souscription_status_model.dart @@ -0,0 +1,53 @@ +/// Statut courant d'une souscription retourné par le backend +class SouscriptionStatusModel { + final String souscriptionId; + final String statutValidation; + final String typeFormule; + final String plageMembres; + final String plageLibelle; + final String typePeriode; + final String typeOrganisation; + final double? montantTotal; + final double? montantMensuelBase; + final double? coefficientApplique; + final String? waveSessionId; + final String? waveLaunchUrl; + final String organisationId; + final String? organisationNom; + + const SouscriptionStatusModel({ + required this.souscriptionId, + required this.statutValidation, + required this.typeFormule, + required this.plageMembres, + required this.plageLibelle, + required this.typePeriode, + required this.typeOrganisation, + this.montantTotal, + this.montantMensuelBase, + this.coefficientApplique, + this.waveSessionId, + this.waveLaunchUrl, + required this.organisationId, + this.organisationNom, + }); + + factory SouscriptionStatusModel.fromJson(Map json) { + return SouscriptionStatusModel( + souscriptionId: json['souscriptionId'] as String, + statutValidation: json['statutValidation'] as String, + typeFormule: json['typeFormule'] as String, + plageMembres: json['plageMembres'] as String, + plageLibelle: (json['plageLibelle'] as String?) ?? '', + typePeriode: json['typePeriode'] as String, + typeOrganisation: (json['typeOrganisation'] as String?) ?? 'ASSOCIATION', + montantTotal: (json['montantTotal'] as num?)?.toDouble(), + montantMensuelBase: (json['montantMensuelBase'] as num?)?.toDouble(), + coefficientApplique: (json['coefficientApplique'] as num?)?.toDouble(), + waveSessionId: json['waveSessionId'] as String?, + waveLaunchUrl: json['waveLaunchUrl'] as String?, + organisationId: json['organisationId'] as String, + organisationNom: json['organisationNom'] as String?, + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart b/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart new file mode 100644 index 0000000..7a01e8c --- /dev/null +++ b/lib/features/onboarding/presentation/pages/awaiting_validation_page.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../data/models/souscription_status_model.dart'; +import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Étape 5 — En attente de validation SuperAdmin +class AwaitingValidationPage extends StatefulWidget { + final SouscriptionStatusModel? souscription; + + const AwaitingValidationPage({super.key, this.souscription}); + + @override + State createState() => _AwaitingValidationPageState(); +} + +class _AwaitingValidationPageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnimation; + Timer? _refreshTimer; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 0.85, 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), (_) { + if (mounted) { + context.read().add(const AuthStatusChecked()); + } + }); + } + + @override + void dispose() { + _pulseController.dispose(); + _refreshTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sosc = widget.souscription; + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animation d'attente + ScaleTransition( + scale: _pulseAnimation, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFF57C00), width: 3), + ), + child: const Icon( + Icons.hourglass_top_rounded, + size: 60, + color: Color(0xFFF57C00), + ), + ), + ), + const SizedBox(height: 32), + + const Text( + 'Demande soumise !', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + 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), + 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, + ), + 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'), + ), + + const SizedBox(height: 12), + TextButton( + onPressed: () => + context.read().add(const AuthLogoutRequested()), + child: const Text('Se déconnecter'), + ), + ], + ), + ), + ), + ); + } +} + +class _SummaryCard extends StatelessWidget { + final SouscriptionStatusModel souscription; + const _SummaryCard({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]!), + ), + 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'), + ], + ), + ); + } +} + +class _Row extends StatelessWidget { + final String label; + final String value; + const _Row(this.label, this.value); + + @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)), + ], + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart new file mode 100644 index 0000000..b2adb6e --- /dev/null +++ b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../bloc/onboarding_bloc.dart'; +import '../../../../core/di/injection.dart'; +import 'plan_selection_page.dart'; +import 'period_selection_page.dart'; +import 'subscription_summary_page.dart'; +import 'wave_payment_page.dart'; +import 'awaiting_validation_page.dart'; + +/// Page conteneur du workflow d'onboarding. +/// Reçoit l'état initial du backend (onboardingState) et dispatch au bon écran. +class OnboardingFlowPage extends StatelessWidget { + final String onboardingState; + final String? souscriptionId; + final String organisationId; + + const OnboardingFlowPage({ + super.key, + required this.onboardingState, + required this.organisationId, + this.souscriptionId, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt() + ..add(OnboardingStarted( + initialState: onboardingState, + existingSouscriptionId: souscriptionId, + )), + child: const _OnboardingFlowView(), + ); + } +} + +class _OnboardingFlowView extends StatelessWidget { + const _OnboardingFlowView(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is OnboardingLoading || state is OnboardingInitial) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + if (state is OnboardingError) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + 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), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.read().add( + OnboardingStarted(initialState: 'NO_SUBSCRIPTION'), + ), + child: const Text('Réessayer'), + ), + ], + ), + ), + ), + ); + } + + if (state is OnboardingStepFormule) { + return PlanSelectionPage(formules: state.formules); + } + + if (state is OnboardingStepPeriode) { + return PeriodSelectionPage( + codeFormule: state.codeFormule, + plage: state.plage, + formules: state.formules, + ); + } + + if (state is OnboardingStepSummary) { + return SubscriptionSummaryPage(souscription: state.souscription); + } + + if (state is OnboardingStepPaiement) { + return WavePaymentPage( + souscription: state.souscription, + waveLaunchUrl: state.waveLaunchUrl, + ); + } + + if (state is OnboardingStepAttente) { + return AwaitingValidationPage(souscription: state.souscription); + } + + if (state is OnboardingRejected) { + return _RejectedPage(commentaire: state.commentaire); + } + + return const Scaffold(body: Center(child: CircularProgressIndicator())); + }, + ); + } +} + +class _RejectedPage extends StatelessWidget { + final String? commentaire; + const _RejectedPage({this.commentaire}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + 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), + ), + if (commentaire != null) ...[ + const SizedBox(height: 12), + Text(commentaire!, textAlign: TextAlign.center), + ], + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => context.read().add( + OnboardingStarted(initialState: 'NO_SUBSCRIPTION'), + ), + child: const Text('Nouvelle souscription'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/period_selection_page.dart b/lib/features/onboarding/presentation/pages/period_selection_page.dart new file mode 100644 index 0000000..af140d8 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/period_selection_page.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +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'; + +/// Étape 2 — Choix de la période de facturation et du type d'organisation +class PeriodSelectionPage extends StatefulWidget { + final String codeFormule; + final String plage; + final List formules; + + const PeriodSelectionPage({ + super.key, + required this.codeFormule, + required this.plage, + required this.formules, + }); + + @override + State createState() => _PeriodSelectionPageState(); +} + +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'), + ]; + + FormuleModel? get _formule => widget.formules + .where((f) => f.code == widget.codeFormule && f.plage == widget.plage) + .firstOrNull; + + double get _prixEstime { + 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; + } + + String get _organisationId { + final authState = context.read().state; + if (authState is AuthAuthenticated) { + return authState.user.organizationContexts.isNotEmpty + ? authState.user.organizationContexts.first.organizationId + : ''; + } + if (authState is AuthPendingOnboarding) { + return authState.organisationId ?? ''; + } + return ''; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + 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, + children: [ + const Text('Estimation', style: TextStyle(fontWeight: FontWeight.bold)), + Text( + '${_prixEstime.toStringAsFixed(0)} FCFA', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: theme.primaryColor, + ), + ), + ], + ), + ), + 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'), + ), + ), + ); + } +} + +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/plan_selection_page.dart b/lib/features/onboarding/presentation/pages/plan_selection_page.dart new file mode 100644 index 0000000..92d39e9 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/plan_selection_page.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../bloc/onboarding_bloc.dart'; +import '../../data/models/formule_model.dart'; + +/// Étape 1 — Choix de la formule (BASIC / STANDARD / PREMIUM) et de la plage de membres +class PlanSelectionPage extends StatefulWidget { + final List formules; + const PlanSelectionPage({super.key, required this.formules}); + + @override + State createState() => _PlanSelectionPageState(); +} + +class _PlanSelectionPageState extends State { + String? _selectedPlage; + 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'), + ]; + + static const _formules = [ + ('BASIC', 'Basic', Icons.star_outline, Color(0xFF1976D2)), + ('STANDARD', 'Standard', Icons.star_half, Color(0xFF388E3C)), + ('PREMIUM', 'Premium', Icons.star, Color(0xFFF57C00)), + ]; + + 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; + + @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])), + ], + ), + selected: selected, + onSelected: (_) => setState(() { + _selectedPlage = code; + _selectedFormule = null; + }), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ); + }).toList(), + ), + + 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), + ], + ), + ), + 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), + ), + child: const Text('Continuer'), + ), + ), + ); + } +} + +class _FormuleCard extends StatelessWidget { + final FormuleModel formule; + final String label; + final IconData icon; + final Color color; + final bool selected; + final VoidCallback onTap; + + const _FormuleCard({ + required this.formule, + required this.label, + required this.icon, + required this.color, + 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: 8), + decoration: BoxDecoration( + color: selected ? color.withOpacity(0.1) : Colors.white, + border: Border.all( + color: selected ? color : Colors.grey[300]!, + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + 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)), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${_formatPrix(formule.prixMensuel)} FCFA', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: color), + ), + 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, + ), + ], + ), + ), + ), + ); + } + + String _formatPrix(double prix) { + if (prix >= 1000) { + return '${(prix / 1000).toStringAsFixed(0)} 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 new file mode 100644 index 0000000..4613e65 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/subscription_summary_page.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../bloc/onboarding_bloc.dart'; +import '../../data/models/souscription_status_model.dart'; + +/// Étape 3 — Récapitulatif avant paiement +class SubscriptionSummaryPage extends StatelessWidget { + final SouscriptionStatusModel souscription; + + const SubscriptionSummaryPage({super.key, required this.souscription}); + + static const _periodeLabels = { + 'MENSUEL': 'Mensuel', + 'TRIMESTRIEL': 'Trimestriel (–5%)', + 'SEMESTRIEL': 'Semestriel (–10%)', + 'ANNUEL': 'Annuel (–20%)', + }; + + static const _orgLabels = { + 'ASSOCIATION': 'Association / ONG locale', + 'MUTUELLE': 'Mutuelle', + 'COOPERATIVE': 'Coopérative / Microfinance', + 'FEDERATION': 'Fédération / Grande ONG', + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final montant = souscription.montantTotal ?? 0; + + 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)], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + 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, + ), + ), + Text( + 'à régler par Wave Mobile Money', + style: TextStyle(color: Colors.white.withOpacity(0.85)), + ), + ], + ), + ), + 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, + ), + _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, + ), + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final bool bold; + + const _InfoRow({required this.label, required this.value, this.bold = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 160, + child: Text(label, + style: const TextStyle(color: Colors.grey, fontSize: 14)), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontWeight: bold ? FontWeight.bold : FontWeight.normal, + fontSize: bold ? 16 : 14, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/wave_payment_page.dart b/lib/features/onboarding/presentation/pages/wave_payment_page.dart new file mode 100644 index 0000000..4879923 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/wave_payment_page.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +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'; + +/// Étape 4 — Lancement du paiement Wave + attente du retour +class WavePaymentPage extends StatefulWidget { + final SouscriptionStatusModel souscription; + final String waveLaunchUrl; + + const WavePaymentPage({ + super.key, + required this.souscription, + required this.waveLaunchUrl, + }); + + @override + State createState() => _WavePaymentPageState(); +} + +class _WavePaymentPageState extends State + with WidgetsBindingObserver { + bool _paymentLaunched = false; + bool _appResumed = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @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 { + final uri = Uri.parse(widget.waveLaunchUrl); + if (await canLaunchUrl(uri)) { + setState(() => _paymentLaunched = true); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + 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, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final montant = widget.souscription.montantTotal ?? 0; + + 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), + ), + child: const Icon(Icons.waves, color: Colors.white, size: 52), + ), + const SizedBox(height: 32), + + 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), + + 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, + ), + ), + ] 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'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/organizations/bloc/org_types_bloc.dart b/lib/features/organizations/bloc/org_types_bloc.dart new file mode 100644 index 0000000..47d4bc3 --- /dev/null +++ b/lib/features/organizations/bloc/org_types_bloc.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import '../domain/entities/type_reference_entity.dart'; +import '../domain/usecases/get_org_types.dart'; +import '../domain/usecases/create_org_type.dart'; +import '../domain/usecases/update_org_type.dart'; +import '../domain/usecases/delete_org_type.dart'; + +part 'org_types_event.dart'; +part 'org_types_state.dart'; + +@injectable +class OrgTypesBloc extends Bloc { + final GetOrgTypes _getOrgTypes; + final CreateOrgType _createOrgType; + final UpdateOrgType _updateOrgType; + final DeleteOrgType _deleteOrgType; + + OrgTypesBloc( + this._getOrgTypes, + this._createOrgType, + this._updateOrgType, + this._deleteOrgType, + ) : super(const OrgTypesInitial()) { + on(_onLoad); + on(_onCreate); + on(_onUpdate); + on(_onDelete); + } + + Future _onLoad(LoadOrgTypes event, Emitter emit) async { + emit(const OrgTypesLoading()); + try { + final types = await _getOrgTypes(); + emit(OrgTypesLoaded(types)); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(OrgTypesError(e.toString())); + } + } + + Future _onCreate(CreateOrgTypeEvent event, Emitter emit) async { + final current = state is OrgTypesLoaded ? (state as OrgTypesLoaded).types : []; + emit(OrgTypeOperating(List.from(current))); + try { + await _createOrgType( + code: event.code, + libelle: event.libelle, + description: event.description, + couleur: event.couleur, + ordreAffichage: event.ordreAffichage, + ); + final types = await _getOrgTypes(); + emit(OrgTypeSuccess(types: types, message: 'Type créé avec succès')); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(OrgTypeOperationError(types: List.from(current), message: e.toString())); + } + } + + Future _onUpdate(UpdateOrgTypeEvent event, Emitter emit) async { + final current = state is OrgTypesLoaded ? (state as OrgTypesLoaded).types : []; + emit(OrgTypeOperating(List.from(current))); + try { + await _updateOrgType( + id: event.id, + code: event.code, + libelle: event.libelle, + description: event.description, + couleur: event.couleur, + ordreAffichage: event.ordreAffichage, + ); + final types = await _getOrgTypes(); + emit(OrgTypeSuccess(types: types, message: 'Type modifié avec succès')); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(OrgTypeOperationError(types: List.from(current), message: e.toString())); + } + } + + Future _onDelete(DeleteOrgTypeEvent event, Emitter emit) async { + final current = state is OrgTypesLoaded ? (state as OrgTypesLoaded).types : []; + emit(OrgTypeOperating(List.from(current))); + try { + await _deleteOrgType(event.id); + final types = await _getOrgTypes(); + emit(OrgTypeSuccess(types: types, message: 'Type supprimé')); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(OrgTypeOperationError(types: List.from(current), message: e.toString())); + } + } +} diff --git a/lib/features/organizations/bloc/org_types_event.dart b/lib/features/organizations/bloc/org_types_event.dart new file mode 100644 index 0000000..b925151 --- /dev/null +++ b/lib/features/organizations/bloc/org_types_event.dart @@ -0,0 +1,58 @@ +part of 'org_types_bloc.dart'; + +abstract class OrgTypesEvent extends Equatable { + const OrgTypesEvent(); + @override + List get props => []; +} + +class LoadOrgTypes extends OrgTypesEvent { + const LoadOrgTypes(); +} + +class CreateOrgTypeEvent extends OrgTypesEvent { + final String code; + final String libelle; + final String? description; + final String? couleur; + final int ordreAffichage; + + const CreateOrgTypeEvent({ + required this.code, + required this.libelle, + this.description, + this.couleur, + this.ordreAffichage = 0, + }); + + @override + List get props => [code, libelle, description, couleur, ordreAffichage]; +} + +class UpdateOrgTypeEvent extends OrgTypesEvent { + final String id; + final String code; + final String libelle; + final String? description; + final String? couleur; + final int ordreAffichage; + + const UpdateOrgTypeEvent({ + required this.id, + required this.code, + required this.libelle, + this.description, + this.couleur, + this.ordreAffichage = 0, + }); + + @override + List get props => [id, code, libelle, description, couleur, ordreAffichage]; +} + +class DeleteOrgTypeEvent extends OrgTypesEvent { + final String id; + const DeleteOrgTypeEvent(this.id); + @override + List get props => [id]; +} diff --git a/lib/features/organizations/bloc/org_types_state.dart b/lib/features/organizations/bloc/org_types_state.dart new file mode 100644 index 0000000..58b2f08 --- /dev/null +++ b/lib/features/organizations/bloc/org_types_state.dart @@ -0,0 +1,52 @@ +part of 'org_types_bloc.dart'; + +abstract class OrgTypesState extends Equatable { + const OrgTypesState(); + @override + List get props => []; +} + +class OrgTypesInitial extends OrgTypesState { + const OrgTypesInitial(); +} + +class OrgTypesLoading extends OrgTypesState { + const OrgTypesLoading(); +} + +class OrgTypesLoaded extends OrgTypesState { + final List types; + const OrgTypesLoaded(this.types); + @override + List get props => [types]; +} + +class OrgTypesError extends OrgTypesState { + final String message; + const OrgTypesError(this.message); + @override + List get props => [message]; +} + +class OrgTypeOperating extends OrgTypesState { + final List types; + const OrgTypeOperating(this.types); + @override + List get props => [types]; +} + +class OrgTypeSuccess extends OrgTypesState { + final List types; + final String message; + const OrgTypeSuccess({required this.types, required this.message}); + @override + List get props => [types, message]; +} + +class OrgTypeOperationError extends OrgTypesState { + final List types; + final String message; + const OrgTypeOperationError({required this.types, required this.message}); + @override + List get props => [types, message]; +} diff --git a/lib/features/organizations/bloc/organizations_bloc.dart b/lib/features/organizations/bloc/organizations_bloc.dart index 6e202f4..b6b98ec 100644 --- a/lib/features/organizations/bloc/organizations_bloc.dart +++ b/lib/features/organizations/bloc/organizations_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des organisations (Clean Architecture) library organizations_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../data/models/organization_model.dart'; @@ -99,6 +100,7 @@ class OrganizationsBloc extends Bloc { useMesOnly: event.useMesOnly, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors du chargement des organisations', details: e.toString(), @@ -144,6 +146,7 @@ class OrganizationsBloc extends Bloc { currentPage: nextPage, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors du chargement de plus d\'organisations', details: e.toString(), @@ -204,6 +207,7 @@ class OrganizationsBloc extends Bloc { )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la recherche', details: e.toString(), @@ -240,6 +244,7 @@ class OrganizationsBloc extends Bloc { statusFilter: event.statut, )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la recherche avancée', details: e.toString(), @@ -262,6 +267,7 @@ class OrganizationsBloc extends Bloc { emit(OrganizationError('Organisation non trouvée', organizationId: event.id)); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationError( 'Erreur lors du chargement de l\'organisation', organizationId: event.id, @@ -279,12 +285,10 @@ class OrganizationsBloc extends Bloc { try { final createdOrganization = await _createOrganization(event.organization); emit(OrganizationCreated(createdOrganization)); - - // Recharger la liste si elle était déjà chargée - if (state is OrganizationsLoaded) { - add(const RefreshOrganizations()); - } + // Toujours recharger la liste après création + add(const RefreshOrganizations()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la création de l\'organisation', details: e.toString(), @@ -299,6 +303,8 @@ class OrganizationsBloc extends Bloc { ) async { emit(OrganizationUpdating(event.id)); + // Capturer l'état avant tout emit + final previousState = state; try { final updatedOrganization = await _updateOrganization( event.id, @@ -306,20 +312,20 @@ class OrganizationsBloc extends Bloc { ); emit(OrganizationUpdated(updatedOrganization)); - // Mettre à jour la liste si elle était déjà chargée - final currentState = state; - if (currentState is OrganizationsLoaded) { - final updatedList = currentState.organizations.map((org) { + // Mettre à jour la liste en place + if (previousState is OrganizationsLoaded) { + final updatedList = previousState.organizations.map((org) { return org.id == event.id ? updatedOrganization : org; }).toList(); - final filteredList = _applyCurrentFilters(updatedList, currentState); - emit(currentState.copyWith( + final filteredList = _applyCurrentFilters(updatedList, previousState); + emit(previousState.copyWith( organizations: updatedList, filteredOrganizations: filteredList, )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la mise à jour de l\'organisation', details: e.toString(), @@ -334,21 +340,23 @@ class OrganizationsBloc extends Bloc { ) async { emit(OrganizationDeleting(event.id)); + // Capturer l'état avant tout emit + final previousState = state; try { await _deleteOrganization(event.id); emit(OrganizationDeleted(event.id)); - // Retirer de la liste si elle était déjà chargée - final currentState = state; - if (currentState is OrganizationsLoaded) { - final updatedList = currentState.organizations.where((org) => org.id != event.id).toList(); - final filteredList = _applyCurrentFilters(updatedList, currentState); - emit(currentState.copyWith( + // Retirer de la liste en place + if (previousState is OrganizationsLoaded) { + final updatedList = previousState.organizations.where((org) => org.id != event.id).toList(); + final filteredList = _applyCurrentFilters(updatedList, previousState); + emit(previousState.copyWith( organizations: updatedList, filteredOrganizations: filteredList, )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la suppression de l\'organisation', details: e.toString(), @@ -361,26 +369,26 @@ class OrganizationsBloc extends Bloc { ActivateOrganization event, Emitter emit, ) async { + final previousState = state; emit(OrganizationActivating(event.id)); try { final activatedOrganization = await _repository.activateOrganization(event.id); emit(OrganizationActivated(activatedOrganization)); - // Mettre à jour la liste si elle était déjà chargée - final currentState = state; - if (currentState is OrganizationsLoaded) { - final updatedList = currentState.organizations.map((org) { + if (previousState is OrganizationsLoaded) { + final updatedList = previousState.organizations.map((org) { return org.id == event.id ? activatedOrganization : org; }).toList(); - final filteredList = _applyCurrentFilters(updatedList, currentState); - emit(currentState.copyWith( + final filteredList = _applyCurrentFilters(updatedList, previousState); + emit(previousState.copyWith( organizations: updatedList, filteredOrganizations: filteredList, )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de l\'activation de l\'organisation', details: e.toString(), @@ -393,26 +401,26 @@ class OrganizationsBloc extends Bloc { SuspendOrganization event, Emitter emit, ) async { + final previousState = state; emit(OrganizationSuspending(event.id)); try { final suspendedOrganization = await _repository.suspendOrganization(event.id); emit(OrganizationSuspended(suspendedOrganization)); - // Mettre à jour la liste si elle était déjà chargée - final currentState = state; - if (currentState is OrganizationsLoaded) { - final updatedList = currentState.organizations.map((org) { + if (previousState is OrganizationsLoaded) { + final updatedList = previousState.organizations.map((org) { return org.id == event.id ? suspendedOrganization : org; }).toList(); - final filteredList = _applyCurrentFilters(updatedList, currentState); - emit(currentState.copyWith( + final filteredList = _applyCurrentFilters(updatedList, previousState); + emit(previousState.copyWith( organizations: updatedList, filteredOrganizations: filteredList, )); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(OrganizationsError( 'Erreur lors de la suspension de l\'organisation', details: e.toString(), @@ -508,6 +516,7 @@ class OrganizationsBloc extends Bloc { final stats = await _repository.getOrganizationsStats(); emit(OrganizationsStatsLoaded(stats)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(const OrganizationsStatsError('Erreur lors du chargement des statistiques')); } } diff --git a/lib/features/organizations/bloc/organizations_event.dart b/lib/features/organizations/bloc/organizations_event.dart index 9590ca1..62cd3fb 100644 --- a/lib/features/organizations/bloc/organizations_event.dart +++ b/lib/features/organizations/bloc/organizations_event.dart @@ -54,7 +54,7 @@ class SearchOrganizations extends OrganizationsEvent { /// Événement pour recherche avancée class AdvancedSearchOrganizations extends OrganizationsEvent { final String? nom; - final TypeOrganization? type; + final String? type; final StatutOrganization? statut; final String? ville; final String? region; @@ -150,7 +150,7 @@ class FilterOrganizationsByStatus extends OrganizationsEvent { /// Événement pour filtrer les organisations par type class FilterOrganizationsByType extends OrganizationsEvent { - final TypeOrganization? type; + final String? type; const FilterOrganizationsByType(this.type); diff --git a/lib/features/organizations/bloc/organizations_state.dart b/lib/features/organizations/bloc/organizations_state.dart index f5c429c..1ee0d50 100644 --- a/lib/features/organizations/bloc/organizations_state.dart +++ b/lib/features/organizations/bloc/organizations_state.dart @@ -40,7 +40,7 @@ class OrganizationsLoaded extends OrganizationsState { final int currentPage; final String? currentSearch; final StatutOrganization? statusFilter; - final TypeOrganization? typeFilter; + final String? typeFilter; final OrganizationSortType? sortType; final bool sortAscending; final Map? stats; @@ -72,7 +72,7 @@ class OrganizationsLoaded extends OrganizationsState { int? currentPage, String? currentSearch, StatutOrganization? statusFilter, - TypeOrganization? typeFilter, + String? typeFilter, OrganizationSortType? sortType, bool? sortAscending, Map? stats, diff --git a/lib/features/organizations/data/datasources/org_types_remote_datasource.dart b/lib/features/organizations/data/datasources/org_types_remote_datasource.dart new file mode 100644 index 0000000..da2ac05 --- /dev/null +++ b/lib/features/organizations/data/datasources/org_types_remote_datasource.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import 'package:unionflow_mobile_apps/core/network/api_client.dart'; +import '../models/type_reference_model.dart'; + +@injectable +class OrgTypesRemoteDataSource { + final ApiClient _apiClient; + static const _basePath = '/api/references/types-organisation'; + + const OrgTypesRemoteDataSource(this._apiClient); + + Future> getOrgTypes() async { + final response = await _apiClient.get(_basePath); + if (response.statusCode == 200) { + final dynamic raw = response.data; + final List data = raw is List + ? raw + : ((raw as Map)['data'] as List? ?? []); + return data + .map((e) => TypeReferenceModel.fromJson(e as Map)) + .toList(); + } + throw DioException( + requestOptions: response.requestOptions, + response: response, + message: 'Erreur lors de la récupération des types: ${response.statusCode}', + ); + } + + Future createOrgType(Map body) async { + final response = await _apiClient.post(_basePath, data: body); + if (response.statusCode == 200 || response.statusCode == 201) { + return TypeReferenceModel.fromJson(response.data as Map); + } + throw DioException( + requestOptions: response.requestOptions, + response: response, + message: 'Erreur lors de la création du type: ${response.statusCode}', + ); + } + + Future updateOrgType(String id, Map body) async { + final response = await _apiClient.put('$_basePath/$id', data: body); + if (response.statusCode == 200) { + return TypeReferenceModel.fromJson(response.data as Map); + } + throw DioException( + requestOptions: response.requestOptions, + response: response, + message: 'Erreur lors de la mise à jour du type: ${response.statusCode}', + ); + } + + Future deleteOrgType(String id) async { + final response = await _apiClient.delete('$_basePath/$id'); + if (response.statusCode != 200 && response.statusCode != 204) { + throw DioException( + requestOptions: response.requestOptions, + response: response, + message: 'Erreur lors de la suppression du type: ${response.statusCode}', + ); + } + } +} diff --git a/lib/features/organizations/data/models/organization_model.dart b/lib/features/organizations/data/models/organization_model.dart index 8cc10f4..46b7e34 100644 --- a/lib/features/organizations/data/models/organization_model.dart +++ b/lib/features/organizations/data/models/organization_model.dart @@ -7,26 +7,6 @@ import 'package:json_annotation/json_annotation.dart'; part 'organization_model.g.dart'; -/// Énumération des types d'organisation -enum TypeOrganization { - @JsonValue('ASSOCIATION') - association, - @JsonValue('COOPERATIVE') - cooperative, - @JsonValue('LIONS_CLUB') - lionsClub, - @JsonValue('ENTREPRISE') - entreprise, - @JsonValue('ONG') - ong, - @JsonValue('FONDATION') - fondation, - @JsonValue('SYNDICAT') - syndicat, - @JsonValue('AUTRE') - autre, -} - /// Énumération des statuts d'organisation enum StatutOrganization { @JsonValue('ACTIVE') @@ -41,51 +21,6 @@ enum StatutOrganization { enCreation, } -/// Extension pour les types d'organisation -extension TypeOrganizationExtension on TypeOrganization { - String get displayName { - switch (this) { - case TypeOrganization.association: - return 'Association'; - case TypeOrganization.cooperative: - return 'Coopérative'; - case TypeOrganization.lionsClub: - return 'Lions Club'; - case TypeOrganization.entreprise: - return 'Entreprise'; - case TypeOrganization.ong: - return 'ONG'; - case TypeOrganization.fondation: - return 'Fondation'; - case TypeOrganization.syndicat: - return 'Syndicat'; - case TypeOrganization.autre: - return 'Autre'; - } - } - - String get icon { - switch (this) { - case TypeOrganization.association: - return '🏛️'; - case TypeOrganization.cooperative: - return '🤝'; - case TypeOrganization.lionsClub: - return '🦁'; - case TypeOrganization.entreprise: - return '🏢'; - case TypeOrganization.ong: - return '🌍'; - case TypeOrganization.fondation: - return '🏛️'; - case TypeOrganization.syndicat: - return '⚖️'; - case TypeOrganization.autre: - return '📋'; - } - } -} - /// Extension pour les statuts d'organisation extension StatutOrganizationExtension on StatutOrganization { String get displayName { @@ -158,9 +93,9 @@ class OrganizationModel extends Equatable { /// Nom court ou sigle final String? nomCourt; - /// Type d'organisation + /// Type d'organisation (code dynamique depuis /api/references/types-organisation) @JsonKey(name: 'typeOrganisation') - final TypeOrganization typeOrganisation; + final String typeOrganisation; /// Statut de l'organisation final StatutOrganization statut; @@ -182,10 +117,22 @@ class OrganizationModel extends Equatable { /// Téléphone final String? telephone; + /// Téléphone secondaire + @JsonKey(name: 'telephoneSecondaire') + final String? telephoneSecondaire; + + /// Email secondaire + @JsonKey(name: 'emailSecondaire') + final String? emailSecondaire; + /// Site web @JsonKey(name: 'siteWeb') final String? siteWeb; + /// Réseaux sociaux (JSON string) + @JsonKey(name: 'reseauxSociaux') + final String? reseauxSociaux; + /// Adresse complète final String? adresse; @@ -245,6 +192,17 @@ class OrganizationModel extends Equatable { /// Partenaires final String? partenaires; + /// Notes internes + final String? notes; + + /// Libellé résolu du type d'organisation (lecture seule, depuis la réponse API) + @JsonKey(name: 'typeOrganisationLibelle') + final String? typeOrganisationLibelle; + + /// Libellé résolu du statut (lecture seule, depuis la réponse API) + @JsonKey(name: 'statutLibelle') + final String? statutLibelle; + /// Organisation publique @JsonKey(name: 'organisationPublique') final bool organisationPublique; @@ -268,14 +226,17 @@ class OrganizationModel extends Equatable { this.id, required this.nom, this.nomCourt, - this.typeOrganisation = TypeOrganization.association, + this.typeOrganisation = 'ASSOCIATION', this.statut = StatutOrganization.active, this.description, this.dateFondation, this.numeroEnregistrement, this.email, this.telephone, + this.telephoneSecondaire, + this.emailSecondaire, this.siteWeb, + this.reseauxSociaux, this.adresse, this.ville, this.codePostal, @@ -293,6 +254,9 @@ class OrganizationModel extends Equatable { this.activitesPrincipales, this.certifications, this.partenaires, + this.notes, + this.typeOrganisationLibelle, + this.statutLibelle, this.organisationPublique = true, this.accepteNouveauxMembres = true, this.dateCreation, @@ -312,14 +276,17 @@ class OrganizationModel extends Equatable { String? id, String? nom, String? nomCourt, - TypeOrganization? typeOrganisation, + String? typeOrganisation, StatutOrganization? statut, String? description, DateTime? dateFondation, String? numeroEnregistrement, String? email, String? telephone, + String? telephoneSecondaire, + String? emailSecondaire, String? siteWeb, + String? reseauxSociaux, String? adresse, String? ville, String? codePostal, @@ -337,6 +304,9 @@ class OrganizationModel extends Equatable { String? activitesPrincipales, String? certifications, String? partenaires, + String? notes, + String? typeOrganisationLibelle, + String? statutLibelle, bool? organisationPublique, bool? accepteNouveauxMembres, DateTime? dateCreation, @@ -354,7 +324,10 @@ class OrganizationModel extends Equatable { numeroEnregistrement: numeroEnregistrement ?? this.numeroEnregistrement, email: email ?? this.email, telephone: telephone ?? this.telephone, + telephoneSecondaire: telephoneSecondaire ?? this.telephoneSecondaire, + emailSecondaire: emailSecondaire ?? this.emailSecondaire, siteWeb: siteWeb ?? this.siteWeb, + reseauxSociaux: reseauxSociaux ?? this.reseauxSociaux, adresse: adresse ?? this.adresse, ville: ville ?? this.ville, codePostal: codePostal ?? this.codePostal, @@ -372,6 +345,9 @@ class OrganizationModel extends Equatable { activitesPrincipales: activitesPrincipales ?? this.activitesPrincipales, certifications: certifications ?? this.certifications, partenaires: partenaires ?? this.partenaires, + notes: notes ?? this.notes, + typeOrganisationLibelle: typeOrganisationLibelle ?? this.typeOrganisationLibelle, + statutLibelle: statutLibelle ?? this.statutLibelle, organisationPublique: organisationPublique ?? this.organisationPublique, accepteNouveauxMembres: accepteNouveauxMembres ?? this.accepteNouveauxMembres, dateCreation: dateCreation ?? this.dateCreation, @@ -412,7 +388,10 @@ class OrganizationModel extends Equatable { numeroEnregistrement, email, telephone, + telephoneSecondaire, + emailSecondaire, siteWeb, + reseauxSociaux, adresse, ville, codePostal, @@ -430,6 +409,9 @@ class OrganizationModel extends Equatable { activitesPrincipales, certifications, partenaires, + notes, + typeOrganisationLibelle, + statutLibelle, organisationPublique, accepteNouveauxMembres, dateCreation, diff --git a/lib/features/organizations/data/models/organization_model.g.dart b/lib/features/organizations/data/models/organization_model.g.dart index de237bb..253b009 100644 --- a/lib/features/organizations/data/models/organization_model.g.dart +++ b/lib/features/organizations/data/models/organization_model.g.dart @@ -11,9 +11,7 @@ OrganizationModel _$OrganizationModelFromJson(Map json) => id: json['id'] as String?, nom: json['nom'] as String, nomCourt: json['nomCourt'] as String?, - typeOrganisation: $enumDecodeNullable( - _$TypeOrganizationEnumMap, json['typeOrganisation']) ?? - TypeOrganization.association, + typeOrganisation: json['typeOrganisation'] as String? ?? 'ASSOCIATION', statut: $enumDecodeNullable(_$StatutOrganizationEnumMap, json['statut']) ?? StatutOrganization.active, @@ -24,7 +22,10 @@ OrganizationModel _$OrganizationModelFromJson(Map json) => numeroEnregistrement: json['numeroEnregistrement'] as String?, email: json['email'] as String?, telephone: json['telephone'] as String?, + telephoneSecondaire: json['telephoneSecondaire'] as String?, + emailSecondaire: json['emailSecondaire'] as String?, siteWeb: json['siteWeb'] as String?, + reseauxSociaux: json['reseauxSociaux'] as String?, adresse: json['adresse'] as String?, ville: json['ville'] as String?, codePostal: json['codePostal'] as String?, @@ -44,6 +45,9 @@ OrganizationModel _$OrganizationModelFromJson(Map json) => activitesPrincipales: json['activitesPrincipales'] as String?, certifications: json['certifications'] as String?, partenaires: json['partenaires'] as String?, + notes: json['notes'] as String?, + typeOrganisationLibelle: json['typeOrganisationLibelle'] as String?, + statutLibelle: json['statutLibelle'] as String?, organisationPublique: json['organisationPublique'] as bool? ?? true, accepteNouveauxMembres: json['accepteNouveauxMembres'] as bool? ?? true, dateCreation: json['dateCreation'] == null @@ -60,14 +64,17 @@ Map _$OrganizationModelToJson(OrganizationModel instance) => 'id': instance.id, 'nom': instance.nom, 'nomCourt': instance.nomCourt, - 'typeOrganisation': _$TypeOrganizationEnumMap[instance.typeOrganisation]!, + 'typeOrganisation': instance.typeOrganisation, 'statut': _$StatutOrganizationEnumMap[instance.statut]!, 'description': instance.description, 'dateFondation': instance.dateFondation?.toIso8601String(), 'numeroEnregistrement': instance.numeroEnregistrement, 'email': instance.email, 'telephone': instance.telephone, + 'telephoneSecondaire': instance.telephoneSecondaire, + 'emailSecondaire': instance.emailSecondaire, 'siteWeb': instance.siteWeb, + 'reseauxSociaux': instance.reseauxSociaux, 'adresse': instance.adresse, 'ville': instance.ville, 'codePostal': instance.codePostal, @@ -85,6 +92,9 @@ Map _$OrganizationModelToJson(OrganizationModel instance) => 'activitesPrincipales': instance.activitesPrincipales, 'certifications': instance.certifications, 'partenaires': instance.partenaires, + 'notes': instance.notes, + 'typeOrganisationLibelle': instance.typeOrganisationLibelle, + 'statutLibelle': instance.statutLibelle, 'organisationPublique': instance.organisationPublique, 'accepteNouveauxMembres': instance.accepteNouveauxMembres, 'dateCreation': instance.dateCreation?.toIso8601String(), @@ -92,17 +102,6 @@ Map _$OrganizationModelToJson(OrganizationModel instance) => 'actif': instance.actif, }; -const _$TypeOrganizationEnumMap = { - TypeOrganization.association: 'ASSOCIATION', - TypeOrganization.cooperative: 'COOPERATIVE', - TypeOrganization.lionsClub: 'LIONS_CLUB', - TypeOrganization.entreprise: 'ENTREPRISE', - TypeOrganization.ong: 'ONG', - TypeOrganization.fondation: 'FONDATION', - TypeOrganization.syndicat: 'SYNDICAT', - TypeOrganization.autre: 'AUTRE', -}; - const _$StatutOrganizationEnumMap = { StatutOrganization.active: 'ACTIVE', StatutOrganization.inactive: 'INACTIVE', diff --git a/lib/features/organizations/data/models/type_reference_model.dart b/lib/features/organizations/data/models/type_reference_model.dart new file mode 100644 index 0000000..6fb0b77 --- /dev/null +++ b/lib/features/organizations/data/models/type_reference_model.dart @@ -0,0 +1,75 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../domain/entities/type_reference_entity.dart'; + +part 'type_reference_model.g.dart'; + +@JsonSerializable() +class TypeReferenceModel { + final String id; + final String domaine; + final String code; + final String libelle; + final String? description; + final String? icone; + final String? couleur; + final String? severity; + @JsonKey(defaultValue: 0) + final int ordreAffichage; + @JsonKey(defaultValue: false) + final bool estDefaut; + @JsonKey(defaultValue: false) + final bool estSysteme; + @JsonKey(defaultValue: true) + final bool actif; + + const TypeReferenceModel({ + required this.id, + required this.domaine, + required this.code, + required this.libelle, + this.description, + this.icone, + this.couleur, + this.severity, + this.ordreAffichage = 0, + this.estDefaut = false, + this.estSysteme = false, + this.actif = true, + }); + + factory TypeReferenceModel.fromJson(Map json) => + _$TypeReferenceModelFromJson(json); + + Map toJson() => _$TypeReferenceModelToJson(this); + + TypeReferenceEntity toEntity() => TypeReferenceEntity( + id: id, + domaine: domaine, + code: code, + libelle: libelle, + description: description, + icone: icone, + couleur: couleur, + severity: severity, + ordreAffichage: ordreAffichage, + estDefaut: estDefaut, + estSysteme: estSysteme, + actif: actif, + ); + + factory TypeReferenceModel.fromEntity(TypeReferenceEntity entity) => + TypeReferenceModel( + id: entity.id, + domaine: entity.domaine, + code: entity.code, + libelle: entity.libelle, + description: entity.description, + icone: entity.icone, + couleur: entity.couleur, + severity: entity.severity, + ordreAffichage: entity.ordreAffichage, + estDefaut: entity.estDefaut, + estSysteme: entity.estSysteme, + actif: entity.actif, + ); +} diff --git a/lib/features/organizations/data/models/type_reference_model.g.dart b/lib/features/organizations/data/models/type_reference_model.g.dart new file mode 100644 index 0000000..43c5c4a --- /dev/null +++ b/lib/features/organizations/data/models/type_reference_model.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'type_reference_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TypeReferenceModel _$TypeReferenceModelFromJson(Map json) => + TypeReferenceModel( + id: json['id'] as String, + domaine: json['domaine'] as String, + code: json['code'] as String, + libelle: json['libelle'] as String, + description: json['description'] as String?, + icone: json['icone'] as String?, + couleur: json['couleur'] as String?, + severity: json['severity'] as String?, + ordreAffichage: (json['ordreAffichage'] as num?)?.toInt() ?? 0, + estDefaut: json['estDefaut'] as bool? ?? false, + estSysteme: json['estSysteme'] as bool? ?? false, + actif: json['actif'] as bool? ?? true, + ); + +Map _$TypeReferenceModelToJson(TypeReferenceModel instance) => + { + 'id': instance.id, + 'domaine': instance.domaine, + 'code': instance.code, + 'libelle': instance.libelle, + 'description': instance.description, + 'icone': instance.icone, + 'couleur': instance.couleur, + 'severity': instance.severity, + 'ordreAffichage': instance.ordreAffichage, + 'estDefaut': instance.estDefaut, + 'estSysteme': instance.estSysteme, + 'actif': instance.actif, + }; diff --git a/lib/features/organizations/data/repositories/org_types_repository_impl.dart b/lib/features/organizations/data/repositories/org_types_repository_impl.dart new file mode 100644 index 0000000..aef9379 --- /dev/null +++ b/lib/features/organizations/data/repositories/org_types_repository_impl.dart @@ -0,0 +1,81 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../domain/entities/type_reference_entity.dart'; +import '../../domain/repositories/org_types_repository.dart'; +import '../datasources/org_types_remote_datasource.dart'; + +@LazySingleton(as: IOrgTypesRepository) +class OrgTypesRepositoryImpl implements IOrgTypesRepository { + final OrgTypesRemoteDataSource _dataSource; + + const OrgTypesRepositoryImpl(this._dataSource); + + @override + Future> getOrgTypes() async { + try { + final models = await _dataSource.getOrgTypes(); + return models.map((m) => m.toEntity()).toList(); + } on DioException catch (e) { + throw Exception('Erreur réseau: ${e.message}'); + } + } + + @override + Future createOrgType({ + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage = 0, + }) async { + try { + final model = await _dataSource.createOrgType({ + 'domaine': 'TYPE_ORGANISATION', + 'code': code.toUpperCase().replaceAll(' ', '_'), + 'libelle': libelle, + if (description != null) 'description': description, + if (couleur != null) 'couleur': couleur, + 'ordreAffichage': ordreAffichage, + 'estDefaut': false, + 'estSysteme': false, + 'actif': true, + }); + return model.toEntity(); + } on DioException catch (e) { + throw Exception('Erreur réseau: ${e.message}'); + } + } + + @override + Future updateOrgType({ + required String id, + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage = 0, + }) async { + try { + final model = await _dataSource.updateOrgType(id, { + 'domaine': 'TYPE_ORGANISATION', + 'code': code.toUpperCase().replaceAll(' ', '_'), + 'libelle': libelle, + if (description != null) 'description': description, + if (couleur != null) 'couleur': couleur, + 'ordreAffichage': ordreAffichage, + }); + return model.toEntity(); + } on DioException catch (e) { + throw Exception('Erreur réseau: ${e.message}'); + } + } + + @override + Future deleteOrgType(String id) async { + try { + await _dataSource.deleteOrgType(id); + } on DioException catch (e) { + throw Exception('Erreur réseau: ${e.message}'); + } + } +} diff --git a/lib/features/organizations/data/repositories/organization_repository.dart b/lib/features/organizations/data/repositories/organization_repository.dart index a82ef69..6a5abdd 100644 --- a/lib/features/organizations/data/repositories/organization_repository.dart +++ b/lib/features/organizations/data/repositories/organization_repository.dart @@ -38,8 +38,9 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { ); if (response.statusCode == 200) { - // Le backend retourne directement une liste [...] - final List data = response.data as List; + // Le backend retourne une réponse paginée {"data":[...],"total":0,...} + final responseData = response.data as Map; + final List data = (responseData['data'] as List?) ?? []; return data .map((json) => OrganizationModel.fromJson(json as Map)) .toList(); @@ -47,6 +48,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la récupération des organisations: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; throw Exception('Erreur réseau lors de la récupération des organisations: ${e.message}'); } catch (e) { throw Exception('Erreur inattendue lors de la récupération des organisations: $e'); @@ -59,13 +61,17 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { const String path = '$_baseUrl/mes'; final response = await _apiClient.get(path); if (response.statusCode == 200) { - final List data = response.data as List; + final dynamic raw = response.data; + final List data = raw is List + ? raw + : ((raw as Map)['data'] as List? ?? []); return data .map((json) => OrganizationModel.fromJson(json as Map)) .toList(); } throw Exception('Erreur lors de la récupération de mes organisations: ${response.statusCode}'); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; throw Exception('Erreur réseau lors de la récupération de mes organisations: ${e.message}'); } catch (e) { throw Exception('Erreur inattendue lors de la récupération de mes organisations: $e'); @@ -85,6 +91,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la récupération de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { return null; } @@ -108,6 +115,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la création de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 400) { final errorData = e.response?.data; if (errorData is Map && errorData.containsKey('error')) { @@ -136,6 +144,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la mise à jour de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } else if (e.response?.statusCode == 400) { @@ -159,6 +168,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la suppression de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } else if (e.response?.statusCode == 400) { @@ -184,6 +194,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de l\'activation de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } @@ -204,6 +215,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la suspension de l\'organisation: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } @@ -216,7 +228,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { @override Future> searchOrganizations({ String? nom, - TypeOrganization? type, + String? type, StatutOrganization? statut, String? ville, String? region, @@ -231,7 +243,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { }; if (nom?.isNotEmpty == true) queryParams['nom'] = nom; - if (type != null) queryParams['type'] = type.name.toUpperCase(); + if (type != null) queryParams['type'] = type.toUpperCase(); if (statut != null) queryParams['statut'] = statut.name.toUpperCase(); if (ville?.isNotEmpty == true) queryParams['ville'] = ville; if (region?.isNotEmpty == true) queryParams['region'] = region; @@ -243,8 +255,8 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { ); if (response.statusCode == 200) { - // Le backend retourne directement une liste [...] - final List data = response.data as List; + final responseData = response.data as Map; + final List data = (responseData['data'] as List?) ?? []; return data .map((json) => OrganizationModel.fromJson(json as Map)) .toList(); @@ -252,6 +264,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la recherche d\'organisations: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; throw Exception('Erreur réseau lors de la recherche d\'organisations: ${e.message}'); } catch (e) { throw Exception('Erreur inattendue lors de la recherche d\'organisations: $e'); @@ -264,12 +277,16 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { final response = await _apiClient.get('$_baseUrl/$organizationId/membres'); if (response.statusCode == 200) { - final List data = response.data as List; + final dynamic raw = response.data; + final List data = raw is List + ? raw + : ((raw as Map)['data'] as List? ?? []); return data.map((e) => Map.from(e as Map)).toList(); } else { throw Exception('Erreur lors de la récupération des membres: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } @@ -296,6 +313,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la mise à jour de la configuration: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) { throw Exception('Organisation non trouvée'); } else if (e.response?.statusCode == 400) { @@ -321,6 +339,7 @@ class OrganizationRepositoryImpl implements IOrganizationRepository { throw Exception('Erreur lors de la récupération des statistiques: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; throw Exception('Erreur réseau lors de la récupération des statistiques: ${e.message}'); } catch (e) { throw Exception('Erreur inattendue lors de la récupération des statistiques: $e'); diff --git a/lib/features/organizations/data/services/organization_service.dart b/lib/features/organizations/data/services/organization_service.dart index 6cc185f..29c479c 100644 --- a/lib/features/organizations/data/services/organization_service.dart +++ b/lib/features/organizations/data/services/organization_service.dart @@ -122,7 +122,7 @@ class OrganizationService { /// Recherche avancée d'organisations Future> searchOrganizations({ String? nom, - TypeOrganization? type, + String? type, StatutOrganization? statut, String? ville, String? region, @@ -166,7 +166,7 @@ class OrganizationService { /// Filtre les organisations par type List filterByType( List organizations, - TypeOrganization type, + String type, ) { return organizations.where((org) => org.typeOrganisation == type).toList(); } @@ -251,7 +251,7 @@ class OrganizationService { // Statistiques par type final parType = {}; for (final org in organizations) { - final type = org.typeOrganisation.displayName; + final type = org.typeOrganisation; parType[type] = (parType[type] ?? 0) + 1; } diff --git a/lib/features/organizations/domain/entities/type_reference_entity.dart b/lib/features/organizations/domain/entities/type_reference_entity.dart new file mode 100644 index 0000000..5566ae0 --- /dev/null +++ b/lib/features/organizations/domain/entities/type_reference_entity.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +/// Entité Type de référence (ex: Type d'organisation) +class TypeReferenceEntity extends Equatable { + final String id; + final String domaine; + final String code; + final String libelle; + final String? description; + final String? icone; + final String? couleur; + final String? severity; + final int ordreAffichage; + final bool estDefaut; + final bool estSysteme; + final bool actif; + + const TypeReferenceEntity({ + required this.id, + required this.domaine, + required this.code, + required this.libelle, + this.description, + this.icone, + this.couleur, + this.severity, + this.ordreAffichage = 0, + this.estDefaut = false, + this.estSysteme = false, + this.actif = true, + }); + + @override + List get props => [id, domaine, code, libelle, description, icone, couleur, severity, ordreAffichage, estDefaut, estSysteme, actif]; +} diff --git a/lib/features/organizations/domain/repositories/org_types_repository.dart b/lib/features/organizations/domain/repositories/org_types_repository.dart new file mode 100644 index 0000000..18d6837 --- /dev/null +++ b/lib/features/organizations/domain/repositories/org_types_repository.dart @@ -0,0 +1,21 @@ +import '../entities/type_reference_entity.dart'; + +abstract class IOrgTypesRepository { + Future> getOrgTypes(); + Future createOrgType({ + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage, + }); + Future updateOrgType({ + required String id, + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage, + }); + Future deleteOrgType(String id); +} diff --git a/lib/features/organizations/domain/repositories/organization_repository.dart b/lib/features/organizations/domain/repositories/organization_repository.dart index fe59ecb..3be7f6d 100644 --- a/lib/features/organizations/domain/repositories/organization_repository.dart +++ b/lib/features/organizations/domain/repositories/organization_repository.dart @@ -37,7 +37,7 @@ abstract class IOrganizationRepository { /// Recherche avancée d'organisations Future> searchOrganizations({ String? nom, - TypeOrganization? type, + String? type, StatutOrganization? statut, String? ville, String? region, diff --git a/lib/features/organizations/domain/usecases/create_org_type.dart b/lib/features/organizations/domain/usecases/create_org_type.dart new file mode 100644 index 0000000..6a6db45 --- /dev/null +++ b/lib/features/organizations/domain/usecases/create_org_type.dart @@ -0,0 +1,23 @@ +import 'package:injectable/injectable.dart'; +import '../entities/type_reference_entity.dart'; +import '../repositories/org_types_repository.dart'; + +@injectable +class CreateOrgType { + final IOrgTypesRepository _repository; + const CreateOrgType(this._repository); + + Future call({ + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage = 0, + }) => _repository.createOrgType( + code: code, + libelle: libelle, + description: description, + couleur: couleur, + ordreAffichage: ordreAffichage, + ); +} diff --git a/lib/features/organizations/domain/usecases/delete_org_type.dart b/lib/features/organizations/domain/usecases/delete_org_type.dart new file mode 100644 index 0000000..ffca469 --- /dev/null +++ b/lib/features/organizations/domain/usecases/delete_org_type.dart @@ -0,0 +1,10 @@ +import 'package:injectable/injectable.dart'; +import '../repositories/org_types_repository.dart'; + +@injectable +class DeleteOrgType { + final IOrgTypesRepository _repository; + const DeleteOrgType(this._repository); + + Future call(String id) => _repository.deleteOrgType(id); +} diff --git a/lib/features/organizations/domain/usecases/get_org_types.dart b/lib/features/organizations/domain/usecases/get_org_types.dart new file mode 100644 index 0000000..ea9cbc1 --- /dev/null +++ b/lib/features/organizations/domain/usecases/get_org_types.dart @@ -0,0 +1,11 @@ +import 'package:injectable/injectable.dart'; +import '../entities/type_reference_entity.dart'; +import '../repositories/org_types_repository.dart'; + +@injectable +class GetOrgTypes { + final IOrgTypesRepository _repository; + const GetOrgTypes(this._repository); + + Future> call() => _repository.getOrgTypes(); +} diff --git a/lib/features/organizations/domain/usecases/update_org_type.dart b/lib/features/organizations/domain/usecases/update_org_type.dart new file mode 100644 index 0000000..08b526f --- /dev/null +++ b/lib/features/organizations/domain/usecases/update_org_type.dart @@ -0,0 +1,25 @@ +import 'package:injectable/injectable.dart'; +import '../entities/type_reference_entity.dart'; +import '../repositories/org_types_repository.dart'; + +@injectable +class UpdateOrgType { + final IOrgTypesRepository _repository; + const UpdateOrgType(this._repository); + + Future call({ + required String id, + required String code, + required String libelle, + String? description, + String? couleur, + int ordreAffichage = 0, + }) => _repository.updateOrgType( + id: id, + code: code, + libelle: libelle, + description: description, + couleur: couleur, + ordreAffichage: ordreAffichage, + ); +} diff --git a/lib/features/organizations/presentation/pages/create_organization_page.dart b/lib/features/organizations/presentation/pages/create_organization_page.dart index 261b29c..5eecea5 100644 --- a/lib/features/organizations/presentation/pages/create_organization_page.dart +++ b/lib/features/organizations/presentation/pages/create_organization_page.dart @@ -1,5 +1,4 @@ -/// Page de création d'une nouvelle organisation -/// Respecte strictement le design system établi dans l'application +/// Page de création d'une nouvelle organisation — tous les champs exhaustifs library create_organisation_page; import 'package:flutter/material.dart'; @@ -8,8 +7,13 @@ import '../../data/models/organization_model.dart'; import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; import '../../bloc/organizations_state.dart'; +import '../../bloc/org_types_bloc.dart'; +import '../../domain/entities/type_reference_entity.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; +import '../../../../core/di/injection_container.dart'; + +const List _devises = ['XOF', 'XAF', 'EUR', 'USD', 'GBP', 'CAD', 'CHF', 'MAD', 'GHS', 'NGN', 'CDF', 'KES']; -/// Page de création d'organisation avec design system cohérent class CreateOrganizationPage extends StatefulWidget { const CreateOrganizationPage({super.key}); @@ -19,54 +23,99 @@ class CreateOrganizationPage extends StatefulWidget { class _CreateOrganizationPageState extends State { final _formKey = GlobalKey(); + + // Informations de base final _nomController = TextEditingController(); final _nomCourtController = TextEditingController(); final _descriptionController = TextEditingController(); + + // Informations légales + final _numeroEnregistrementController = TextEditingController(); + DateTime? _dateFondation; + + // Contact final _emailController = TextEditingController(); final _telephoneController = TextEditingController(); + final _telephoneSecondaireController = TextEditingController(); + final _emailSecondaireController = TextEditingController(); final _siteWebController = TextEditingController(); + final _reseauxSociauxController = TextEditingController(); + + // Localisation final _adresseController = TextEditingController(); final _villeController = TextEditingController(); + final _codePostalController = TextEditingController(); final _regionController = TextEditingController(); final _paysController = TextEditingController(); - TypeOrganization _selectedType = TypeOrganization.association; + // Finances + String _selectedDevise = 'XOF'; + final _budgetAnnuelController = TextEditingController(); + bool _cotisationObligatoire = false; + final _montantCotisationAnnuelleController = TextEditingController(); + + // Mission & contenu + final _objectifsController = TextEditingController(); + final _activitesPrincipalesController = TextEditingController(); + final _certificationsController = TextEditingController(); + final _partenairesController = TextEditingController(); + final _notesController = TextEditingController(); + + // Configuration + String? _selectedTypeCode; StatutOrganization _selectedStatut = StatutOrganization.active; + bool _accepteNouveauxMembres = true; + bool _organisationPublique = true; + + late final OrgTypesBloc _orgTypesBloc; + + @override + void initState() { + super.initState(); + _orgTypesBloc = sl()..add(const LoadOrgTypes()); + } @override void dispose() { + _orgTypesBloc.close(); _nomController.dispose(); _nomCourtController.dispose(); _descriptionController.dispose(); + _numeroEnregistrementController.dispose(); _emailController.dispose(); _telephoneController.dispose(); + _telephoneSecondaireController.dispose(); + _emailSecondaireController.dispose(); _siteWebController.dispose(); + _reseauxSociauxController.dispose(); _adresseController.dispose(); _villeController.dispose(); + _codePostalController.dispose(); _regionController.dispose(); _paysController.dispose(); + _budgetAnnuelController.dispose(); + _montantCotisationAnnuelleController.dispose(); + _objectifsController.dispose(); + _activitesPrincipalesController.dispose(); + _certificationsController.dispose(); + _partenairesController.dispose(); + _notesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), // Background cohérent + backgroundColor: AppColors.lightBackground, appBar: AppBar( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, title: const Text('Nouvelle Organisation'), elevation: 0, actions: [ TextButton( onPressed: _isFormValid() ? _saveOrganisation : null, - child: const Text( - 'Enregistrer', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), + child: const Text('Enregistrer', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), ), ], ), @@ -74,36 +123,38 @@ class _CreateOrganizationPageState extends State { listener: (context, state) { if (state is OrganizationCreated) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Organisation créée avec succès'), - backgroundColor: Color(0xFF10B981), - ), + const SnackBar(content: Text('Organisation créée avec succès'), backgroundColor: AppColors.success), ); - Navigator.of(context).pop(true); // Retour avec succès + Navigator.of(context).pop(true); } else if (state is OrganizationsError) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.red, - ), + SnackBar(content: Text(state.message), backgroundColor: Colors.red), ); } }, child: Form( key: _formKey, child: SingleChildScrollView( - padding: const EdgeInsets.all(12), // SpacingTokens cohérent + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildBasicInfoCard(), - const SizedBox(height: 16), - _buildContactCard(), - const SizedBox(height: 16), - _buildLocationCard(), - const SizedBox(height: 16), - _buildConfigurationCard(), - const SizedBox(height: 24), + _buildSection('Informations de base', Icons.business, _buildBasicInfoFields()), + const SizedBox(height: 8), + _buildSection('Informations légales', Icons.gavel, _buildLegalFields()), + const SizedBox(height: 8), + _buildSection('Contact', Icons.contact_phone, _buildContactFields()), + const SizedBox(height: 8), + _buildSection('Localisation', Icons.location_on, _buildLocationFields()), + const SizedBox(height: 8), + _buildSection('Configuration', Icons.settings, _buildConfigurationFields()), + const SizedBox(height: 8), + _buildSection('Finances', Icons.account_balance_wallet, _buildFinancesFields()), + const SizedBox(height: 8), + _buildSection('Mission & Activités', Icons.flag, _buildMissionFields()), + const SizedBox(height: 8), + _buildSection('Informations complémentaires', Icons.info_outline, _buildSupplementaryFields()), + const SizedBox(height: 8), _buildActionButtons(), ], ), @@ -113,421 +164,370 @@ class _CreateOrganizationPageState extends State { ); } - /// Carte des informations de base - Widget _buildBasicInfoCard() { + Widget _buildSection(String title, IconData icon, List children) { return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Informations de base', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _nomController, - decoration: const InputDecoration( - labelText: 'Nom de l\'organisation *', - hintText: 'Ex: Association des Développeurs', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.business), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Le nom est obligatoire'; - } - if (value.trim().length < 3) { - return 'Le nom doit contenir au moins 3 caractères'; - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - TextFormField( - controller: _nomCourtController, - decoration: const InputDecoration( - labelText: 'Nom court (optionnel)', - hintText: 'Ex: AsDev', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.short_text), - ), - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 2) { - return 'Le nom court doit contenir au moins 2 caractères'; - } - return null; - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - value: _selectedType, - decoration: const InputDecoration( - labelText: 'Type d\'organisation *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: TypeOrganization.values.map((type) { - return DropdownMenuItem( - value: type, - child: Row( - children: [ - Text(type.icon, style: const TextStyle(fontSize: 16)), - const SizedBox(width: 8), - Text(type.displayName), - ], - ), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedType = value; - }); - } - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description (optionnel)', - hintText: 'Décrivez brièvement l\'organisation...', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.description), - ), - maxLines: 3, - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 10) { - return 'La description doit contenir au moins 10 caractères'; - } - return null; - }, - ), + Row(children: [ + Icon(icon, size: 16, color: AppColors.primaryGreen), + const SizedBox(width: 6), + Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppColors.primaryGreen)), + ]), + const SizedBox(height: 10), + ...children, ], ), ); } - /// Carte des informations de contact - Widget _buildContactCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Contact', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: 'Email (optionnel)', - hintText: 'contact@organisation.com', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.email), - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value != null && value.trim().isNotEmpty) { - final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$'); - if (!emailRegex.hasMatch(value.trim())) { - return 'Format d\'email invalide'; - } - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _telephoneController, - decoration: const InputDecoration( - labelText: 'Téléphone (optionnel)', - hintText: '+225 XX XX XX XX XX', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.phone), - ), - keyboardType: TextInputType.phone, - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 8) { - return 'Numéro de téléphone invalide'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _siteWebController, - decoration: const InputDecoration( - labelText: 'Site web (optionnel)', - hintText: 'https://www.organisation.com', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.web), - ), - keyboardType: TextInputType.url, - validator: (value) { - if (value != null && value.trim().isNotEmpty) { - final urlRegex = RegExp(r'^https?://[^\s]+$'); - if (!urlRegex.hasMatch(value.trim())) { - return 'Format d\'URL invalide (doit commencer par http:// ou https://)'; - } - } - return null; - }, - ), - ], - ), - ); - } + List _buildBasicInfoFields() => [ + TextFormField( + controller: _nomController, + decoration: const InputDecoration(labelText: 'Nom de l\'organisation *', hintText: 'Ex: Mutuelle des Entrepreneurs', border: OutlineInputBorder(), prefixIcon: Icon(Icons.business)), + validator: (v) => v == null || v.trim().length < 3 ? 'Minimum 3 caractères' : null, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _nomCourtController, + decoration: const InputDecoration(labelText: 'Sigle / Nom court', hintText: 'Ex: MUKEFI', border: OutlineInputBorder(), prefixIcon: Icon(Icons.short_text)), + validator: (v) => v != null && v.trim().isNotEmpty && v.trim().length < 2 ? 'Minimum 2 caractères' : null, + ), + const SizedBox(height: 8), + BlocBuilder( + bloc: _orgTypesBloc, + builder: (context, orgTypesState) { + final types = orgTypesState is OrgTypesLoaded ? orgTypesState.types + : orgTypesState is OrgTypeSuccess ? orgTypesState.types + : []; + if (orgTypesState is OrgTypesLoading || orgTypesState is OrgTypesInitial) { + return const InputDecorator( + decoration: InputDecoration(labelText: 'Type d\'organisation *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.category)), + child: LinearProgressIndicator(), + ); + } + return DropdownButtonFormField( + value: types.any((t) => t.code == _selectedTypeCode) ? _selectedTypeCode : null, + decoration: const InputDecoration(labelText: 'Type d\'organisation *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.category)), + items: types.map((t) => DropdownMenuItem(value: t.code, child: Text(t.libelle))).toList(), + onChanged: (v) => setState(() => _selectedTypeCode = v), + validator: (v) => v == null ? 'Le type est obligatoire' : null, + ); + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration(labelText: 'Description', hintText: 'Décrivez brièvement l\'organisation...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.description)), + maxLines: 3, + validator: (v) => v != null && v.trim().isNotEmpty && v.trim().length < 10 ? 'Minimum 10 caractères' : null, + ), + ]; - /// Carte de localisation - Widget _buildLocationCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + List _buildLegalFields() => [ + TextFormField( + controller: _numeroEnregistrementController, + decoration: const InputDecoration(labelText: 'Numéro d\'enregistrement officiel', hintText: 'Ex: CI-ASSOC-2024-001', border: OutlineInputBorder(), prefixIcon: Icon(Icons.assignment)), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => _pickDateFondation(context), + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Date de fondation', border: OutlineInputBorder(), prefixIcon: Icon(Icons.cake)), + child: Text( + _dateFondation != null ? _formatDate(_dateFondation) : 'Sélectionner une date', + style: TextStyle(color: _dateFondation != null ? AppColors.textPrimaryLight : AppColors.textSecondaryLight), + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Localisation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _adresseController, - decoration: const InputDecoration( - labelText: 'Adresse (optionnel)', - hintText: 'Rue, quartier...', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on), - ), - maxLines: 2, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _villeController, - decoration: const InputDecoration( - labelText: 'Ville', - hintText: 'Abidjan', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_city), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _regionController, - decoration: const InputDecoration( - labelText: 'Région', - hintText: 'Lagunes', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.map), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _paysController, - decoration: const InputDecoration( - labelText: 'Pays', - hintText: 'Côte d\'Ivoire', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.flag), - ), - ), - ], - ), - ); - } + ), + ]; - /// Carte de configuration - Widget _buildConfigurationCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Configuration', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - value: _selectedStatut, - decoration: const InputDecoration( - labelText: 'Statut initial *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.toggle_on), - ), - items: StatutOrganization.values.map((statut) { - final color = Color(int.parse(statut.color.substring(1), radix: 16) + 0xFF000000); - return DropdownMenuItem( - value: statut, - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text(statut.displayName), - ], - ), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedStatut = value; - }); - } - }, - ), - ], - ), - ); - } + List _buildContactFields() => [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email principal *', hintText: 'contact@organisation.com', border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'L\'email est obligatoire'; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(v.trim())) return 'Format invalide'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _emailSecondaireController, + decoration: const InputDecoration(labelText: 'Email secondaire', hintText: 'info@organisation.com', border: OutlineInputBorder(), prefixIcon: Icon(Icons.alternate_email)), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v != null && v.trim().isNotEmpty && !RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(v.trim())) return 'Format invalide'; + return null; + }, + ), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _telephoneController, + decoration: const InputDecoration(labelText: 'Téléphone principal', hintText: '+225 XX XX XX', border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone)), + keyboardType: TextInputType.phone, + validator: (v) => v != null && v.trim().isNotEmpty && v.trim().length < 8 ? 'Numéro invalide' : null, + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _telephoneSecondaireController, + decoration: const InputDecoration(labelText: 'Téléphone secondaire', hintText: '+225 XX XX XX', border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone_forwarded)), + keyboardType: TextInputType.phone, + validator: (v) => v != null && v.trim().isNotEmpty && v.trim().length < 8 ? 'Numéro invalide' : null, + )), + ]), + const SizedBox(height: 8), + TextFormField( + controller: _siteWebController, + decoration: const InputDecoration(labelText: 'Site web', hintText: 'https://www.organisation.com', border: OutlineInputBorder(), prefixIcon: Icon(Icons.web)), + keyboardType: TextInputType.url, + validator: (v) { + if (v != null && v.trim().isNotEmpty && !RegExp(r'^https?://[^\s]+$').hasMatch(v.trim())) return 'Doit commencer par http:// ou https://'; + return null; + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _reseauxSociauxController, + decoration: const InputDecoration(labelText: 'Réseaux sociaux', hintText: 'Ex: Facebook, LinkedIn, Twitter...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.share)), + maxLines: 2, + ), + ]; + + List _buildLocationFields() => [ + TextFormField( + controller: _adresseController, + decoration: const InputDecoration(labelText: 'Adresse', hintText: 'Rue, quartier...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_on)), + maxLines: 2, + ), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _villeController, + decoration: const InputDecoration(labelText: 'Ville', hintText: 'Abidjan', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_city)), + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _codePostalController, + decoration: const InputDecoration(labelText: 'Code postal', hintText: '01 BP 1234', border: OutlineInputBorder(), prefixIcon: Icon(Icons.markunread_mailbox)), + )), + ]), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _regionController, + decoration: const InputDecoration(labelText: 'Région', hintText: 'Lagunes', border: OutlineInputBorder(), prefixIcon: Icon(Icons.map)), + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _paysController, + decoration: const InputDecoration(labelText: 'Pays', hintText: 'Côte d\'Ivoire', border: OutlineInputBorder(), prefixIcon: Icon(Icons.flag)), + )), + ]), + ]; + + List _buildConfigurationFields() => [ + DropdownButtonFormField( + value: _selectedStatut, + decoration: const InputDecoration(labelText: 'Statut initial *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.toggle_on)), + items: StatutOrganization.values.map((s) { + final color = Color(int.parse(s.color.substring(1), radix: 16) + 0xFF000000); + return DropdownMenuItem(value: s, child: Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 8), + Text(s.displayName), + ])); + }).toList(), + onChanged: (v) { if (v != null) setState(() => _selectedStatut = v); }, + ), + const SizedBox(height: 8), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Organisation publique', style: TextStyle(fontSize: 14)), + subtitle: const Text('Visible par tous les utilisateurs', style: TextStyle(fontSize: 12)), + value: _organisationPublique, + onChanged: (v) => setState(() => _organisationPublique = v), + activeColor: AppColors.primaryGreen, + ), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Accepte de nouveaux membres', style: TextStyle(fontSize: 14)), + subtitle: const Text('Les demandes d\'adhésion sont ouvertes', style: TextStyle(fontSize: 12)), + value: _accepteNouveauxMembres, + onChanged: (v) => setState(() => _accepteNouveauxMembres = v), + activeColor: AppColors.primaryGreen, + ), + ]; + + List _buildFinancesFields() => [ + DropdownButtonFormField( + value: _selectedDevise, + decoration: const InputDecoration(labelText: 'Devise', border: OutlineInputBorder(), prefixIcon: Icon(Icons.currency_exchange)), + items: _devises.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(), + onChanged: (v) { if (v != null) setState(() => _selectedDevise = v); }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _budgetAnnuelController, + decoration: const InputDecoration(labelText: 'Budget annuel', hintText: 'Ex: 5000000', border: OutlineInputBorder(), prefixIcon: Icon(Icons.account_balance)), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v != null && v.trim().isNotEmpty && double.tryParse(v.trim()) == null) return 'Montant invalide'; + return null; + }, + ), + const SizedBox(height: 8), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Cotisation obligatoire', style: TextStyle(fontSize: 14)), + value: _cotisationObligatoire, + onChanged: (v) => setState(() => _cotisationObligatoire = v), + activeColor: AppColors.primaryGreen, + ), + if (_cotisationObligatoire) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _montantCotisationAnnuelleController, + decoration: InputDecoration(labelText: 'Montant cotisation annuelle ($_selectedDevise)', hintText: 'Ex: 25000', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.payments)), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (_cotisationObligatoire && (v == null || v.trim().isEmpty)) return 'Obligatoire si cotisation requise'; + if (v != null && v.trim().isNotEmpty && double.tryParse(v.trim()) == null) return 'Montant invalide'; + return null; + }, + ), + ], + ]; + + List _buildMissionFields() => [ + TextFormField( + controller: _objectifsController, + decoration: const InputDecoration(labelText: 'Objectifs', hintText: 'Décrire les objectifs principaux...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.track_changes)), + maxLines: 3, + ), + const SizedBox(height: 8), + TextFormField( + controller: _activitesPrincipalesController, + decoration: const InputDecoration(labelText: 'Activités principales', hintText: 'Lister les activités clés...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.work)), + maxLines: 3, + ), + ]; + + List _buildSupplementaryFields() => [ + TextFormField( + controller: _certificationsController, + decoration: const InputDecoration(labelText: 'Certifications / Agréments', hintText: 'Ex: ISO 9001, Agrément ministériel...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.verified)), + ), + const SizedBox(height: 8), + TextFormField( + controller: _partenairesController, + decoration: const InputDecoration(labelText: 'Partenaires', hintText: 'Ex: Banque Mondiale, Ministère...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.handshake)), + maxLines: 2, + ), + const SizedBox(height: 8), + TextFormField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'Notes internes', hintText: 'Informations internes non visibles publiquement...', border: OutlineInputBorder(), prefixIcon: Icon(Icons.sticky_note_2)), + maxLines: 2, + ), + ]; - /// Boutons d'action Widget _buildActionButtons() { - return Column( - children: [ - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isFormValid() ? _saveOrganisation : null, - icon: const Icon(Icons.save), - label: const Text('Créer l\'organisation'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), + return Column(children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isFormValid() ? _saveOrganisation : null, + icon: const Icon(Icons.save), + label: const Text('Créer l\'organisation'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.cancel), - label: const Text('Annuler'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF6B7280), - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.cancel), + label: const Text('Annuler'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondaryLight, + padding: const EdgeInsets.symmetric(vertical: 10), ), ), - ], + ), + ]); + } + + bool _isFormValid() => + _nomController.text.trim().isNotEmpty && + _emailController.text.trim().isNotEmpty; + + Future _pickDateFondation(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: _dateFondation ?? DateTime(2000), + firstDate: DateTime(1800), + lastDate: DateTime.now(), ); + if (picked != null) setState(() => _dateFondation = picked); } - /// Vérifie si le formulaire est valide - bool _isFormValid() { - return _nomController.text.trim().isNotEmpty; + String _formatDate(DateTime? date) { + if (date == null) return ''; + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; } - /// Sauvegarde l'organisation void _saveOrganisation() { if (_formKey.currentState?.validate() ?? false) { - final organisation = OrganizationModel( + final org = OrganizationModel( nom: _nomController.text.trim(), nomCourt: _nomCourtController.text.trim().isEmpty ? null : _nomCourtController.text.trim(), description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), - typeOrganisation: _selectedType, + typeOrganisation: _selectedTypeCode ?? 'ASSOCIATION', statut: _selectedStatut, + dateFondation: _dateFondation, + numeroEnregistrement: _numeroEnregistrementController.text.trim().isEmpty ? null : _numeroEnregistrementController.text.trim(), email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(), + telephoneSecondaire: _telephoneSecondaireController.text.trim().isEmpty ? null : _telephoneSecondaireController.text.trim(), + emailSecondaire: _emailSecondaireController.text.trim().isEmpty ? null : _emailSecondaireController.text.trim(), siteWeb: _siteWebController.text.trim().isEmpty ? null : _siteWebController.text.trim(), + reseauxSociaux: _reseauxSociauxController.text.trim().isEmpty ? null : _reseauxSociauxController.text.trim(), adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(), ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(), + codePostal: _codePostalController.text.trim().isEmpty ? null : _codePostalController.text.trim(), region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(), pays: _paysController.text.trim().isEmpty ? null : _paysController.text.trim(), - dateCreation: DateTime.now(), + devise: _selectedDevise, + budgetAnnuel: _budgetAnnuelController.text.trim().isEmpty ? null : double.tryParse(_budgetAnnuelController.text.trim()), + cotisationObligatoire: _cotisationObligatoire, + montantCotisationAnnuelle: _montantCotisationAnnuelleController.text.trim().isEmpty ? null : double.tryParse(_montantCotisationAnnuelleController.text.trim()), + objectifs: _objectifsController.text.trim().isEmpty ? null : _objectifsController.text.trim(), + activitesPrincipales: _activitesPrincipalesController.text.trim().isEmpty ? null : _activitesPrincipalesController.text.trim(), + certifications: _certificationsController.text.trim().isEmpty ? null : _certificationsController.text.trim(), + partenaires: _partenairesController.text.trim().isEmpty ? null : _partenairesController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + organisationPublique: _organisationPublique, + accepteNouveauxMembres: _accepteNouveauxMembres, nombreMembres: 0, ); - - context.read().add(CreateOrganization(organisation)); + context.read().add(CreateOrganization(org)); } } } diff --git a/lib/features/organizations/presentation/pages/edit_organization_page.dart b/lib/features/organizations/presentation/pages/edit_organization_page.dart index 98e5983..377c4d4 100644 --- a/lib/features/organizations/presentation/pages/edit_organization_page.dart +++ b/lib/features/organizations/presentation/pages/edit_organization_page.dart @@ -1,5 +1,4 @@ -/// Page d'édition d'une organisation existante -/// Respecte strictement le design system établi dans l'application +/// Page d'édition d'une organisation — tous les champs exhaustifs library edit_organisation_page; import 'package:flutter/material.dart'; @@ -8,15 +7,17 @@ import '../../data/models/organization_model.dart'; import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; import '../../bloc/organizations_state.dart'; +import '../../bloc/org_types_bloc.dart'; +import '../../domain/entities/type_reference_entity.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; +import '../../../../core/di/injection_container.dart'; + +const List _devisesEdit = ['XOF', 'XAF', 'EUR', 'USD', 'GBP', 'CAD', 'CHF', 'MAD', 'GHS', 'NGN', 'CDF', 'KES']; -/// Page d'édition d'organisation avec design system cohérent class EditOrganizationPage extends StatefulWidget { final OrganizationModel organization; - const EditOrganizationPage({ - super.key, - required this.organization, - }); + const EditOrganizationPage({super.key, required this.organization}); @override State createState() => _EditOrganizationPageState(); @@ -24,682 +25,682 @@ class EditOrganizationPage extends StatefulWidget { class _EditOrganizationPageState extends State { final _formKey = GlobalKey(); + + // Informations de base late final TextEditingController _nomController; late final TextEditingController _nomCourtController; late final TextEditingController _descriptionController; + + // Informations légales + late final TextEditingController _numeroEnregistrementController; + DateTime? _dateFondation; + + // Contact late final TextEditingController _emailController; late final TextEditingController _telephoneController; + late final TextEditingController _telephoneSecondaireController; + late final TextEditingController _emailSecondaireController; late final TextEditingController _siteWebController; + late final TextEditingController _reseauxSociauxController; + + // Localisation late final TextEditingController _adresseController; late final TextEditingController _villeController; + late final TextEditingController _codePostalController; late final TextEditingController _regionController; late final TextEditingController _paysController; - late TypeOrganization _selectedType; + // Finances + late String _selectedDevise; + late final TextEditingController _budgetAnnuelController; + late bool _cotisationObligatoire; + late final TextEditingController _montantCotisationAnnuelleController; + + // Mission & contenu + late final TextEditingController _objectifsController; + late final TextEditingController _activitesPrincipalesController; + late final TextEditingController _certificationsController; + late final TextEditingController _partenairesController; + late final TextEditingController _notesController; + + // Configuration + late String _selectedTypeCode; late StatutOrganization _selectedStatut; + late bool _accepteNouveauxMembres; + late bool _organisationPublique; + + late final OrgTypesBloc _orgTypesBloc; + late final OrganizationsBloc _detailBloc; @override void initState() { super.initState(); - // Initialiser les contrôleurs avec les valeurs existantes - _nomController = TextEditingController(text: widget.organization.nom); - _nomCourtController = TextEditingController(text: widget.organization.nomCourt ?? ''); - _descriptionController = TextEditingController(text: widget.organization.description ?? ''); - _emailController = TextEditingController(text: widget.organization.email ?? ''); - _telephoneController = TextEditingController(text: widget.organization.telephone ?? ''); - _siteWebController = TextEditingController(text: widget.organization.siteWeb ?? ''); - _adresseController = TextEditingController(text: widget.organization.adresse ?? ''); - _villeController = TextEditingController(text: widget.organization.ville ?? ''); - _regionController = TextEditingController(text: widget.organization.region ?? ''); - _paysController = TextEditingController(text: widget.organization.pays ?? ''); + final org = widget.organization; - _selectedType = widget.organization.typeOrganisation; - _selectedStatut = widget.organization.statut; + _nomController = TextEditingController(text: org.nom); + _nomCourtController = TextEditingController(text: org.nomCourt ?? ''); + _descriptionController = TextEditingController(text: org.description ?? ''); + _numeroEnregistrementController = TextEditingController(text: org.numeroEnregistrement ?? ''); + _dateFondation = org.dateFondation; + + _emailController = TextEditingController(text: org.email ?? ''); + _telephoneController = TextEditingController(text: org.telephone ?? ''); + _telephoneSecondaireController = TextEditingController(text: org.telephoneSecondaire ?? ''); + _emailSecondaireController = TextEditingController(text: org.emailSecondaire ?? ''); + _siteWebController = TextEditingController(text: org.siteWeb ?? ''); + _reseauxSociauxController = TextEditingController(text: org.reseauxSociaux ?? ''); + + _adresseController = TextEditingController(text: org.adresse ?? ''); + _villeController = TextEditingController(text: org.ville ?? ''); + _codePostalController = TextEditingController(text: org.codePostal ?? ''); + _regionController = TextEditingController(text: org.region ?? ''); + _paysController = TextEditingController(text: org.pays ?? ''); + + _selectedDevise = _devisesEdit.contains(org.devise) ? org.devise : 'XOF'; + _budgetAnnuelController = TextEditingController(text: org.budgetAnnuel?.toString() ?? ''); + _cotisationObligatoire = org.cotisationObligatoire; + _montantCotisationAnnuelleController = TextEditingController(text: org.montantCotisationAnnuelle?.toString() ?? ''); + + _objectifsController = TextEditingController(text: org.objectifs ?? ''); + _activitesPrincipalesController = TextEditingController(text: org.activitesPrincipales ?? ''); + _certificationsController = TextEditingController(text: org.certifications ?? ''); + _partenairesController = TextEditingController(text: org.partenaires ?? ''); + _notesController = TextEditingController(text: org.notes ?? ''); + + _selectedTypeCode = org.typeOrganisation; + _selectedStatut = org.statut; + _accepteNouveauxMembres = org.accepteNouveauxMembres; + _organisationPublique = org.organisationPublique; + + _orgTypesBloc = sl()..add(const LoadOrgTypes()); + _detailBloc = sl(); + if (org.id != null) { + _detailBloc.add(LoadOrganizationById(org.id!)); + } + } + + void _refillForm(OrganizationModel org) { + _nomController.text = org.nom; + _nomCourtController.text = org.nomCourt ?? ''; + _descriptionController.text = org.description ?? ''; + _numeroEnregistrementController.text = org.numeroEnregistrement ?? ''; + _emailController.text = org.email ?? ''; + _telephoneController.text = org.telephone ?? ''; + _telephoneSecondaireController.text = org.telephoneSecondaire ?? ''; + _emailSecondaireController.text = org.emailSecondaire ?? ''; + _siteWebController.text = org.siteWeb ?? ''; + _reseauxSociauxController.text = org.reseauxSociaux ?? ''; + _adresseController.text = org.adresse ?? ''; + _villeController.text = org.ville ?? ''; + _codePostalController.text = org.codePostal ?? ''; + _regionController.text = org.region ?? ''; + _paysController.text = org.pays ?? ''; + _budgetAnnuelController.text = org.budgetAnnuel?.toString() ?? ''; + _montantCotisationAnnuelleController.text = org.montantCotisationAnnuelle?.toString() ?? ''; + _objectifsController.text = org.objectifs ?? ''; + _activitesPrincipalesController.text = org.activitesPrincipales ?? ''; + _certificationsController.text = org.certifications ?? ''; + _partenairesController.text = org.partenaires ?? ''; + _notesController.text = org.notes ?? ''; + setState(() { + _selectedTypeCode = org.typeOrganisation; + _selectedStatut = org.statut; + _dateFondation = org.dateFondation; + _selectedDevise = _devisesEdit.contains(org.devise) ? org.devise : 'XOF'; + _cotisationObligatoire = org.cotisationObligatoire; + _accepteNouveauxMembres = org.accepteNouveauxMembres; + _organisationPublique = org.organisationPublique; + }); } @override void dispose() { + _orgTypesBloc.close(); + _detailBloc.close(); _nomController.dispose(); _nomCourtController.dispose(); _descriptionController.dispose(); + _numeroEnregistrementController.dispose(); _emailController.dispose(); _telephoneController.dispose(); + _telephoneSecondaireController.dispose(); + _emailSecondaireController.dispose(); _siteWebController.dispose(); + _reseauxSociauxController.dispose(); _adresseController.dispose(); _villeController.dispose(); + _codePostalController.dispose(); _regionController.dispose(); _paysController.dispose(); + _budgetAnnuelController.dispose(); + _montantCotisationAnnuelleController.dispose(); + _objectifsController.dispose(); + _activitesPrincipalesController.dispose(); + _certificationsController.dispose(); + _partenairesController.dispose(); + _notesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), // Background cohérent + backgroundColor: AppColors.lightBackground, appBar: AppBar( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, title: const Text('Modifier Organisation'), elevation: 0, actions: [ TextButton( onPressed: _hasChanges() ? _saveChanges : null, - child: const Text( - 'Enregistrer', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), + child: const Text('Enregistrer', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), ), ], ), body: BlocListener( + bloc: _detailBloc, listener: (context, state) { - if (state is OrganizationUpdated) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Organisation modifiée avec succès'), - backgroundColor: Color(0xFF10B981), - ), - ); - Navigator.of(context).pop(true); // Retour avec succès - } else if (state is OrganizationsError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.red, - ), - ); - } + if (state is OrganizationLoaded) _refillForm(state.organization); }, - child: Form( - key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), // SpacingTokens cohérent - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBasicInfoCard(), - const SizedBox(height: 16), - _buildContactCard(), - const SizedBox(height: 16), - _buildLocationCard(), - const SizedBox(height: 16), - _buildConfigurationCard(), - const SizedBox(height: 16), - _buildMetadataCard(), - const SizedBox(height: 24), - _buildActionButtons(), - ], - ), - ), - ), - ), - ); - } - - /// Carte des informations de base - Widget _buildBasicInfoCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Informations de base', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _nomController, - decoration: const InputDecoration( - labelText: 'Nom de l\'organisation *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.business), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Le nom est obligatoire'; - } - if (value.trim().length < 3) { - return 'Le nom doit contenir au moins 3 caractères'; - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - TextFormField( - controller: _nomCourtController, - decoration: const InputDecoration( - labelText: 'Nom court (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.short_text), - ), - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 2) { - return 'Le nom court doit contenir au moins 2 caractères'; - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - value: _selectedType, - decoration: const InputDecoration( - labelText: 'Type d\'organisation *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: TypeOrganization.values.map((type) { - return DropdownMenuItem( - value: type, - child: Row( - children: [ - Text(type.icon, style: const TextStyle(fontSize: 16)), - const SizedBox(width: 8), - Text(type.displayName), - ], - ), + child: BlocListener( + listener: (context, state) { + if (state is OrganizationUpdated) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Organisation modifiée avec succès'), backgroundColor: AppColors.success), ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedType = value; - }); - } - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.description), - ), - maxLines: 3, - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 10) { - return 'La description doit contenir au moins 10 caractères'; - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - ], - ), - ); - } - - /// Carte des informations de contact - Widget _buildContactCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Contact', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: 'Email (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.email), - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value != null && value.trim().isNotEmpty) { - final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$'); - if (!emailRegex.hasMatch(value.trim())) { - return 'Format d\'email invalide'; - } - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - TextFormField( - controller: _telephoneController, - decoration: const InputDecoration( - labelText: 'Téléphone (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.phone), - ), - keyboardType: TextInputType.phone, - validator: (value) { - if (value != null && value.trim().isNotEmpty && value.trim().length < 8) { - return 'Numéro de téléphone invalide'; - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - TextFormField( - controller: _siteWebController, - decoration: const InputDecoration( - labelText: 'Site web (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.web), - ), - keyboardType: TextInputType.url, - validator: (value) { - if (value != null && value.trim().isNotEmpty) { - final urlRegex = RegExp(r'^https?://[^\s]+$'); - if (!urlRegex.hasMatch(value.trim())) { - return 'Format d\'URL invalide (doit commencer par http:// ou https://)'; - } - } - return null; - }, - onChanged: (_) => setState(() {}), - ), - ], - ), - ); - } - - /// Carte de localisation - Widget _buildLocationCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Localisation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _adresseController, - decoration: const InputDecoration( - labelText: 'Adresse (optionnel)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on), - ), - maxLines: 2, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _villeController, - decoration: const InputDecoration( - labelText: 'Ville', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_city), - ), - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _regionController, - decoration: const InputDecoration( - labelText: 'Région', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.map), - ), - onChanged: (_) => setState(() {}), - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _paysController, - decoration: const InputDecoration( - labelText: 'Pays', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.flag), - ), - onChanged: (_) => setState(() {}), - ), - ], - ), - ); - } - - /// Carte de configuration - Widget _buildConfigurationCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Configuration', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - value: _selectedStatut, - decoration: const InputDecoration( - labelText: 'Statut *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.toggle_on), - ), - items: StatutOrganization.values.map((statut) { - final color = Color(int.parse(statut.color.substring(1), radix: 16) + 0xFF000000); - return DropdownMenuItem( - value: statut, - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text(statut.displayName), - ], - ), + Navigator.of(context).pop(true); + } else if (state is OrganizationsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message), backgroundColor: Colors.red), ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedStatut = value; - }); - } - }, + } + }, + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection('Informations de base', Icons.business, _buildBasicInfoFields()), + const SizedBox(height: 8), + _buildSection('Informations légales', Icons.gavel, _buildLegalFields()), + const SizedBox(height: 8), + _buildSection('Contact', Icons.contact_phone, _buildContactFields()), + const SizedBox(height: 8), + _buildSection('Localisation', Icons.location_on, _buildLocationFields()), + const SizedBox(height: 8), + _buildSection('Configuration', Icons.settings, _buildConfigurationFields()), + const SizedBox(height: 8), + _buildSection('Finances', Icons.account_balance_wallet, _buildFinancesFields()), + const SizedBox(height: 8), + _buildSection('Mission & Activités', Icons.flag, _buildMissionFields()), + const SizedBox(height: 8), + _buildSection('Informations complémentaires', Icons.info_outline, _buildSupplementaryFields()), + const SizedBox(height: 8), + _buildSection('Informations système', Icons.admin_panel_settings, _buildMetadataFields()), + const SizedBox(height: 8), + _buildActionButtons(), + ], + ), + ), ), - ], + ), ), ); } - /// Carte des métadonnées (lecture seule) - Widget _buildMetadataCard() { + Widget _buildSection(String title, IconData icon, List children) { return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Informations système', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - _buildReadOnlyField( - icon: Icons.fingerprint, - label: 'ID', - value: widget.organization.id ?? 'Non défini', - ), - const SizedBox(height: 12), - _buildReadOnlyField( - icon: Icons.calendar_today, - label: 'Date de création', - value: _formatDate(widget.organization.dateCreation), - ), - const SizedBox(height: 12), - _buildReadOnlyField( - icon: Icons.people, - label: 'Nombre de membres', - value: widget.organization.nombreMembres.toString(), - ), - if (widget.organization.ancienneteAnnees > 0) ...[ - const SizedBox(height: 12), - _buildReadOnlyField( - icon: Icons.access_time, - label: 'Ancienneté', - value: '${widget.organization.ancienneteAnnees} ans', - ), - ], + Row(children: [ + Icon(icon, size: 16, color: AppColors.primaryGreen), + const SizedBox(width: 6), + Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppColors.primaryGreen)), + ]), + const SizedBox(height: 10), + ...children, ], ), ); } - /// Champ en lecture seule - Widget _buildReadOnlyField({ - required IconData icon, - required String label, - required String value, - }) { - return Row( - children: [ - Icon( - icon, - size: 20, - color: const Color(0xFF6B7280), + List _buildBasicInfoFields() => [ + TextFormField( + controller: _nomController, + decoration: const InputDecoration(labelText: 'Nom de l\'organisation *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.business)), + validator: (v) => v == null || v.trim().length < 3 ? 'Minimum 3 caractères' : null, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _nomCourtController, + decoration: const InputDecoration(labelText: 'Sigle / Nom court', border: OutlineInputBorder(), prefixIcon: Icon(Icons.short_text)), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + BlocBuilder( + bloc: _orgTypesBloc, + builder: (context, orgTypesState) { + final types = orgTypesState is OrgTypesLoaded ? orgTypesState.types + : orgTypesState is OrgTypeSuccess ? orgTypesState.types + : []; + if (orgTypesState is OrgTypesLoading || orgTypesState is OrgTypesInitial) { + return const InputDecorator( + decoration: InputDecoration(labelText: 'Type d\'organisation *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.category)), + child: LinearProgressIndicator(), + ); + } + return DropdownButtonFormField( + value: types.any((t) => t.code == _selectedTypeCode) ? _selectedTypeCode : null, + decoration: const InputDecoration(labelText: 'Type d\'organisation *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.category)), + items: types.map((t) => DropdownMenuItem(value: t.code, child: Text(t.libelle))).toList(), + onChanged: (v) { if (v != null) setState(() => _selectedTypeCode = v); }, + validator: (v) => v == null ? 'Le type est obligatoire' : null, + ); + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration(labelText: 'Description', border: OutlineInputBorder(), prefixIcon: Icon(Icons.description)), + maxLines: 3, + validator: (v) => v != null && v.trim().isNotEmpty && v.trim().length < 10 ? 'Minimum 10 caractères' : null, + onChanged: (_) => setState(() {}), + ), + ]; + + List _buildLegalFields() => [ + TextFormField( + controller: _numeroEnregistrementController, + decoration: const InputDecoration(labelText: 'Numéro d\'enregistrement officiel', border: OutlineInputBorder(), prefixIcon: Icon(Icons.assignment)), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => _pickDateFondation(context), + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Date de fondation', border: OutlineInputBorder(), prefixIcon: Icon(Icons.cake)), + child: Text( + _dateFondation != null ? _formatDate(_dateFondation) : 'Sélectionner une date', + style: TextStyle(color: _dateFondation != null ? AppColors.textPrimaryLight : AppColors.textSecondaryLight), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF6B7280), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF374151), - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ); + ), + ), + ]; + + List _buildContactFields() => [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email principal *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'L\'email est obligatoire'; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(v.trim())) return 'Format invalide'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _emailSecondaireController, + decoration: const InputDecoration(labelText: 'Email secondaire', border: OutlineInputBorder(), prefixIcon: Icon(Icons.alternate_email)), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v != null && v.trim().isNotEmpty && !RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(v.trim())) return 'Format invalide'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _telephoneController, + decoration: const InputDecoration(labelText: 'Téléphone principal', border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone)), + keyboardType: TextInputType.phone, + onChanged: (_) => setState(() {}), + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _telephoneSecondaireController, + decoration: const InputDecoration(labelText: 'Téléphone secondaire', border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone_forwarded)), + keyboardType: TextInputType.phone, + onChanged: (_) => setState(() {}), + )), + ]), + const SizedBox(height: 8), + TextFormField( + controller: _siteWebController, + decoration: const InputDecoration(labelText: 'Site web', border: OutlineInputBorder(), prefixIcon: Icon(Icons.web)), + keyboardType: TextInputType.url, + validator: (v) { + if (v != null && v.trim().isNotEmpty && !RegExp(r'^https?://[^\s]+$').hasMatch(v.trim())) return 'Doit commencer par http:// ou https://'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _reseauxSociauxController, + decoration: const InputDecoration(labelText: 'Réseaux sociaux', border: OutlineInputBorder(), prefixIcon: Icon(Icons.share)), + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + ]; + + List _buildLocationFields() => [ + TextFormField( + controller: _adresseController, + decoration: const InputDecoration(labelText: 'Adresse', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_on)), + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _villeController, + decoration: const InputDecoration(labelText: 'Ville', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_city)), + onChanged: (_) => setState(() {}), + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _codePostalController, + decoration: const InputDecoration(labelText: 'Code postal', border: OutlineInputBorder(), prefixIcon: Icon(Icons.markunread_mailbox)), + onChanged: (_) => setState(() {}), + )), + ]), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextFormField( + controller: _regionController, + decoration: const InputDecoration(labelText: 'Région', border: OutlineInputBorder(), prefixIcon: Icon(Icons.map)), + onChanged: (_) => setState(() {}), + )), + const SizedBox(width: 8), + Expanded(child: TextFormField( + controller: _paysController, + decoration: const InputDecoration(labelText: 'Pays', border: OutlineInputBorder(), prefixIcon: Icon(Icons.flag)), + onChanged: (_) => setState(() {}), + )), + ]), + ]; + + List _buildConfigurationFields() => [ + DropdownButtonFormField( + value: _selectedStatut, + decoration: const InputDecoration(labelText: 'Statut *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.toggle_on)), + items: StatutOrganization.values.map((s) { + final color = Color(int.parse(s.color.substring(1), radix: 16) + 0xFF000000); + return DropdownMenuItem(value: s, child: Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 8), + Text(s.displayName), + ])); + }).toList(), + onChanged: (v) { if (v != null) setState(() => _selectedStatut = v); }, + ), + const SizedBox(height: 8), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Organisation publique', style: TextStyle(fontSize: 14)), + subtitle: const Text('Visible par tous les utilisateurs', style: TextStyle(fontSize: 12)), + value: _organisationPublique, + onChanged: (v) => setState(() => _organisationPublique = v), + activeColor: AppColors.primaryGreen, + ), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Accepte de nouveaux membres', style: TextStyle(fontSize: 14)), + subtitle: const Text('Les demandes d\'adhésion sont ouvertes', style: TextStyle(fontSize: 12)), + value: _accepteNouveauxMembres, + onChanged: (v) => setState(() => _accepteNouveauxMembres = v), + activeColor: AppColors.primaryGreen, + ), + ]; + + List _buildFinancesFields() => [ + DropdownButtonFormField( + value: _selectedDevise, + decoration: const InputDecoration(labelText: 'Devise', border: OutlineInputBorder(), prefixIcon: Icon(Icons.currency_exchange)), + items: _devisesEdit.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(), + onChanged: (v) { if (v != null) setState(() => _selectedDevise = v); }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _budgetAnnuelController, + decoration: const InputDecoration(labelText: 'Budget annuel', border: OutlineInputBorder(), prefixIcon: Icon(Icons.account_balance)), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v != null && v.trim().isNotEmpty && double.tryParse(v.trim()) == null) return 'Montant invalide'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Cotisation obligatoire', style: TextStyle(fontSize: 14)), + value: _cotisationObligatoire, + onChanged: (v) => setState(() => _cotisationObligatoire = v), + activeColor: AppColors.primaryGreen, + ), + if (_cotisationObligatoire) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _montantCotisationAnnuelleController, + decoration: InputDecoration(labelText: 'Montant cotisation annuelle ($_selectedDevise)', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.payments)), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (_cotisationObligatoire && (v == null || v.trim().isEmpty)) return 'Obligatoire si cotisation requise'; + if (v != null && v.trim().isNotEmpty && double.tryParse(v.trim()) == null) return 'Montant invalide'; + return null; + }, + onChanged: (_) => setState(() {}), + ), + ], + ]; + + List _buildMissionFields() => [ + TextFormField( + controller: _objectifsController, + decoration: const InputDecoration(labelText: 'Objectifs', border: OutlineInputBorder(), prefixIcon: Icon(Icons.track_changes)), + maxLines: 3, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _activitesPrincipalesController, + decoration: const InputDecoration(labelText: 'Activités principales', border: OutlineInputBorder(), prefixIcon: Icon(Icons.work)), + maxLines: 3, + onChanged: (_) => setState(() {}), + ), + ]; + + List _buildSupplementaryFields() => [ + TextFormField( + controller: _certificationsController, + decoration: const InputDecoration(labelText: 'Certifications / Agréments', border: OutlineInputBorder(), prefixIcon: Icon(Icons.verified)), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _partenairesController, + decoration: const InputDecoration(labelText: 'Partenaires', border: OutlineInputBorder(), prefixIcon: Icon(Icons.handshake)), + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextFormField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'Notes internes', border: OutlineInputBorder(), prefixIcon: Icon(Icons.sticky_note_2)), + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + ]; + + List _buildMetadataFields() => [ + _buildReadOnlyRow(Icons.fingerprint, 'ID', widget.organization.id ?? '—'), + const SizedBox(height: 8), + _buildReadOnlyRow(Icons.calendar_today, 'Créé le', _formatDate(widget.organization.dateCreation)), + const SizedBox(height: 8), + _buildReadOnlyRow(Icons.edit_calendar, 'Modifié le', _formatDate(widget.organization.dateModification)), + const SizedBox(height: 8), + _buildReadOnlyRow(Icons.people, 'Membres', widget.organization.nombreMembres.toString()), + const SizedBox(height: 8), + _buildReadOnlyRow(Icons.admin_panel_settings, 'Administrateurs', widget.organization.nombreAdministrateurs.toString()), + ]; + + Widget _buildReadOnlyRow(IconData icon, String label, String value) { + return Row(children: [ + Icon(icon, size: 18, color: AppColors.textSecondaryLight), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textSecondaryLight)), + Text(value, style: const TextStyle(fontSize: 13, color: AppColors.textPrimaryLight, fontWeight: FontWeight.w600)), + ])), + ]); } - /// Boutons d'action Widget _buildActionButtons() { - return Column( - children: [ - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _hasChanges() ? _saveChanges : null, - icon: const Icon(Icons.save), - label: const Text('Enregistrer les modifications'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), + return Column(children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _hasChanges() ? _saveChanges : null, + icon: const Icon(Icons.save), + label: const Text('Enregistrer les modifications'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _showDiscardDialog(), - icon: const Icon(Icons.cancel), - label: const Text('Annuler'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF6B7280), - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _showDiscardDialog, + icon: const Icon(Icons.cancel), + label: const Text('Annuler'), + style: OutlinedButton.styleFrom(foregroundColor: AppColors.textSecondaryLight, padding: const EdgeInsets.symmetric(vertical: 10)), ), - ], - ); + ), + ]); } - /// Vérifie s'il y a des changements bool _hasChanges() { - return _nomController.text.trim() != widget.organization.nom || - _nomCourtController.text.trim() != (widget.organization.nomCourt ?? '') || - _descriptionController.text.trim() != (widget.organization.description ?? '') || - _emailController.text.trim() != (widget.organization.email ?? '') || - _telephoneController.text.trim() != (widget.organization.telephone ?? '') || - _siteWebController.text.trim() != (widget.organization.siteWeb ?? '') || - _adresseController.text.trim() != (widget.organization.adresse ?? '') || - _villeController.text.trim() != (widget.organization.ville ?? '') || - _regionController.text.trim() != (widget.organization.region ?? '') || - _paysController.text.trim() != (widget.organization.pays ?? '') || - _selectedType != widget.organization.typeOrganisation || - _selectedStatut != widget.organization.statut; + final org = widget.organization; + return _nomController.text.trim() != org.nom || + _nomCourtController.text.trim() != (org.nomCourt ?? '') || + _descriptionController.text.trim() != (org.description ?? '') || + _numeroEnregistrementController.text.trim() != (org.numeroEnregistrement ?? '') || + _dateFondation != org.dateFondation || + _emailController.text.trim() != (org.email ?? '') || + _telephoneController.text.trim() != (org.telephone ?? '') || + _telephoneSecondaireController.text.trim() != (org.telephoneSecondaire ?? '') || + _emailSecondaireController.text.trim() != (org.emailSecondaire ?? '') || + _siteWebController.text.trim() != (org.siteWeb ?? '') || + _reseauxSociauxController.text.trim() != (org.reseauxSociaux ?? '') || + _adresseController.text.trim() != (org.adresse ?? '') || + _villeController.text.trim() != (org.ville ?? '') || + _codePostalController.text.trim() != (org.codePostal ?? '') || + _regionController.text.trim() != (org.region ?? '') || + _paysController.text.trim() != (org.pays ?? '') || + _selectedDevise != org.devise || + _budgetAnnuelController.text.trim() != (org.budgetAnnuel?.toString() ?? '') || + _cotisationObligatoire != org.cotisationObligatoire || + _montantCotisationAnnuelleController.text.trim() != (org.montantCotisationAnnuelle?.toString() ?? '') || + _objectifsController.text.trim() != (org.objectifs ?? '') || + _activitesPrincipalesController.text.trim() != (org.activitesPrincipales ?? '') || + _certificationsController.text.trim() != (org.certifications ?? '') || + _partenairesController.text.trim() != (org.partenaires ?? '') || + _notesController.text.trim() != (org.notes ?? '') || + _selectedTypeCode != org.typeOrganisation || + _selectedStatut != org.statut || + _accepteNouveauxMembres != org.accepteNouveauxMembres || + _organisationPublique != org.organisationPublique; } - /// Sauvegarde les modifications void _saveChanges() { if (_formKey.currentState?.validate() ?? false) { - final updatedOrganisation = widget.organization.copyWith( + final updated = widget.organization.copyWith( nom: _nomController.text.trim(), nomCourt: _nomCourtController.text.trim().isEmpty ? null : _nomCourtController.text.trim(), description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), - typeOrganisation: _selectedType, + typeOrganisation: _selectedTypeCode, statut: _selectedStatut, + dateFondation: _dateFondation, + numeroEnregistrement: _numeroEnregistrementController.text.trim().isEmpty ? null : _numeroEnregistrementController.text.trim(), email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(), + telephoneSecondaire: _telephoneSecondaireController.text.trim().isEmpty ? null : _telephoneSecondaireController.text.trim(), + emailSecondaire: _emailSecondaireController.text.trim().isEmpty ? null : _emailSecondaireController.text.trim(), siteWeb: _siteWebController.text.trim().isEmpty ? null : _siteWebController.text.trim(), + reseauxSociaux: _reseauxSociauxController.text.trim().isEmpty ? null : _reseauxSociauxController.text.trim(), adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(), ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(), + codePostal: _codePostalController.text.trim().isEmpty ? null : _codePostalController.text.trim(), region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(), pays: _paysController.text.trim().isEmpty ? null : _paysController.text.trim(), + devise: _selectedDevise, + budgetAnnuel: _budgetAnnuelController.text.trim().isEmpty ? null : double.tryParse(_budgetAnnuelController.text.trim()), + cotisationObligatoire: _cotisationObligatoire, + montantCotisationAnnuelle: _montantCotisationAnnuelleController.text.trim().isEmpty ? null : double.tryParse(_montantCotisationAnnuelleController.text.trim()), + objectifs: _objectifsController.text.trim().isEmpty ? null : _objectifsController.text.trim(), + activitesPrincipales: _activitesPrincipalesController.text.trim().isEmpty ? null : _activitesPrincipalesController.text.trim(), + certifications: _certificationsController.text.trim().isEmpty ? null : _certificationsController.text.trim(), + partenaires: _partenairesController.text.trim().isEmpty ? null : _partenairesController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + organisationPublique: _organisationPublique, + accepteNouveauxMembres: _accepteNouveauxMembres, ); - if (widget.organization.id != null) { - context.read().add( - UpdateOrganization(widget.organization.id!, updatedOrganisation), - ); + context.read().add(UpdateOrganization(widget.organization.id!, updated)); } } } - /// Affiche le dialog de confirmation d'annulation void _showDiscardDialog() { - if (_hasChanges()) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Annuler les modifications'), - content: const Text('Vous avez des modifications non sauvegardées. Êtes-vous sûr de vouloir les abandonner ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Continuer l\'édition'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); // Fermer le dialog - Navigator.of(context).pop(); // Retour à la page précédente - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('Abandonner', style: TextStyle(color: Colors.white)), - ), - ], - ), - ); - } else { - Navigator.of(context).pop(); - } + if (!_hasChanges()) { Navigator.of(context).pop(); return; } + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Annuler les modifications'), + content: const Text('Vous avez des modifications non sauvegardées. Voulez-vous les abandonner ?'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Continuer l\'édition')), + ElevatedButton( + onPressed: () { Navigator.of(ctx).pop(); Navigator.of(context).pop(); }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Abandonner', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + Future _pickDateFondation(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: _dateFondation ?? DateTime(2000), + firstDate: DateTime(1800), + lastDate: DateTime.now(), + ); + if (picked != null) setState(() { _dateFondation = picked; }); } - /// Formate une date String _formatDate(DateTime? date) { - if (date == null) return 'Non spécifiée'; - return '${date.day}/${date.month}/${date.year}'; + if (date == null) return '—'; + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; } } diff --git a/lib/features/organizations/presentation/pages/org_types_page.dart b/lib/features/organizations/presentation/pages/org_types_page.dart new file mode 100644 index 0000000..fca04dc --- /dev/null +++ b/lib/features/organizations/presentation/pages/org_types_page.dart @@ -0,0 +1,500 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../shared/design_system/unionflow_design_system.dart'; +import '../../../../shared/widgets/core_card.dart'; +import '../../../../core/di/injection.dart'; +import '../../bloc/org_types_bloc.dart'; +import '../../domain/entities/type_reference_entity.dart'; + +class OrgTypesPage extends StatelessWidget { + const OrgTypesPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt()..add(const LoadOrgTypes()), + child: const _OrgTypesView(), + ); + } +} + +class _OrgTypesView extends StatefulWidget { + const _OrgTypesView(); + @override + State<_OrgTypesView> createState() => _OrgTypesViewState(); +} + +class _OrgTypesViewState extends State<_OrgTypesView> { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.lightBackground, + appBar: const UFAppBar( + title: 'TYPES D\'ORGANISATIONS', + automaticallyImplyLeading: true, + ), + body: BlocConsumer( + listener: (context, state) { + if (state is OrgTypeSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } else if (state is OrgTypeOperationError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + }, + builder: (context, state) { + if (state is OrgTypesLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (state is OrgTypesError) { + return _buildErrorState(context, state.message); + } + + final types = _getTypes(state); + if (types.isEmpty && state is! OrgTypeOperating) { + return _buildEmptyState(context); + } + + return RefreshIndicator( + onRefresh: () async => context.read().add(const LoadOrgTypes()), + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: types.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) => _buildTypeCard(context, types[index], state), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.small( + onPressed: () => _showTypeForm(context, null), + backgroundColor: AppColors.primaryGreen, + child: const Icon(Icons.add, color: Colors.white), + ), + ); + } + + List _getTypes(OrgTypesState state) { + if (state is OrgTypesLoaded) return state.types; + if (state is OrgTypeOperating) return state.types; + if (state is OrgTypeSuccess) return state.types; + if (state is OrgTypeOperationError) return state.types; + return []; + } + + Widget _buildTypeCard(BuildContext context, TypeReferenceEntity type, OrgTypesState state) { + final isOperating = state is OrgTypeOperating; + final color = _parseColor(type.couleur) ?? AppColors.primaryGreen; + + return Opacity( + opacity: isOperating ? 0.6 : 1.0, + child: CoreCard( + margin: EdgeInsets.zero, + onTap: (!type.estSysteme && !isOperating) ? () => _showTypeForm(context, type) : null, + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: color, width: 3)), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.fromLTRB(10, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + type.code, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + color: color, + letterSpacing: 0.5, + ), + ), + ), + if (type.estDefaut) ...[ + const SizedBox(width: 6), + const Icon(Icons.star_rounded, size: 13, color: Color(0xFFF59E0B)), + ], + if (type.estSysteme) ...[ + const SizedBox(width: 6), + Icon(Icons.lock_outline, size: 12, color: Colors.grey[500]), + ], + ], + ), + const SizedBox(height: 4), + Text( + type.libelle, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimaryLight, + ), + ), + if (type.description != null && type.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + type.description!, + style: const TextStyle(fontSize: 11, color: AppColors.textSecondaryLight), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (!type.estSysteme && !isOperating) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 16), + color: AppColors.textSecondaryLight, + onPressed: () => _showTypeForm(context, type), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16), + color: Colors.red[400], + onPressed: () => _confirmDelete(context, type), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.category_outlined, size: 48, color: Colors.grey[400]), + const SizedBox(height: 12), + const Text( + 'Aucun type défini', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimaryLight), + ), + const SizedBox(height: 6), + Text( + 'Créez votre premier type d\'organisation', + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _showTypeForm(context, null), + icon: const Icon(Icons.add, size: 16), + label: const Text('Créer un type'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorState(BuildContext context, String message) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 40, color: Colors.red[400]), + const SizedBox(height: 12), + const Text('Erreur de chargement', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + Text(message, style: const TextStyle(fontSize: 11, color: AppColors.textSecondaryLight), textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.read().add(const LoadOrgTypes()), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + void _showTypeForm(BuildContext context, TypeReferenceEntity? existing) { + final bloc = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _OrgTypeFormSheet(existing: existing, bloc: bloc), + ); + } + + void _confirmDelete(BuildContext context, TypeReferenceEntity type) { + final bloc = context.read(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: const Text('Supprimer ce type ?', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + content: Text( + 'Supprimer "${type.libelle}" (${type.code}) ?\nLes organisations associées devront être mises à jour.', + style: const TextStyle(fontSize: 12), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(ctx); + bloc.add(DeleteOrgTypeEvent(type.id)); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + child: const Text('Supprimer', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + } + + Color? _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return null; + try { + final clean = hex.replaceAll('#', ''); + return Color(int.parse('FF$clean', radix: 16)); + } catch (_) { + return null; + } + } +} + +/// Bottom sheet form for create / edit +class _OrgTypeFormSheet extends StatefulWidget { + final TypeReferenceEntity? existing; + final OrgTypesBloc bloc; + + const _OrgTypeFormSheet({this.existing, required this.bloc}); + + @override + State<_OrgTypeFormSheet> createState() => _OrgTypeFormSheetState(); +} + +class _OrgTypeFormSheetState extends State<_OrgTypeFormSheet> { + late final TextEditingController _codeCtrl; + late final TextEditingController _libelleCtrl; + late final TextEditingController _descCtrl; + String _selectedColor = '#22C55E'; + final _formKey = GlobalKey(); + + static const _colorOptions = [ + '#22C55E', '#3B82F6', '#F59E0B', '#EF4444', + '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', + '#64748B', '#A16207', + ]; + + @override + void initState() { + super.initState(); + _codeCtrl = TextEditingController(text: widget.existing?.code ?? ''); + _libelleCtrl = TextEditingController(text: widget.existing?.libelle ?? ''); + _descCtrl = TextEditingController(text: widget.existing?.description ?? ''); + _selectedColor = widget.existing?.couleur ?? '#22C55E'; + } + + @override + void dispose() { + _codeCtrl.dispose(); + _libelleCtrl.dispose(); + _descCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.existing != null; + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle bar + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 14), + Text( + isEdit ? 'Modifier le type' : 'Nouveau type d\'organisation', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: AppColors.textPrimaryLight), + ), + const SizedBox(height: 14), + + // Code + TextFormField( + controller: _codeCtrl, + decoration: InputDecoration( + labelText: 'Code technique *', + hintText: 'Ex: ASSOCIATION', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + textCapitalization: TextCapitalization.characters, + style: const TextStyle(fontSize: 12), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Code requis' : null, + ), + const SizedBox(height: 10), + + // Libellé + TextFormField( + controller: _libelleCtrl, + decoration: InputDecoration( + labelText: 'Libellé *', + hintText: 'Ex: Association', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + style: const TextStyle(fontSize: 12), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Libellé requis' : null, + ), + const SizedBox(height: 10), + + // Description + TextFormField( + controller: _descCtrl, + decoration: InputDecoration( + labelText: 'Description', + hintText: 'Optionnelle', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + style: const TextStyle(fontSize: 12), + maxLines: 2, + ), + const SizedBox(height: 12), + + // Color picker + const Text('Couleur', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondaryLight)), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: _colorOptions.map((hex) { + final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16)); + final selected = _selectedColor == hex; + return GestureDetector( + onTap: () => setState(() => _selectedColor = hex), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: selected ? Border.all(color: Colors.black, width: 2) : null, + ), + child: selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + // Submit + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text( + isEdit ? 'Enregistrer' : 'Créer le type', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + Navigator.pop(context); + if (widget.existing != null) { + widget.bloc.add(UpdateOrgTypeEvent( + id: widget.existing!.id, + code: _codeCtrl.text.trim(), + libelle: _libelleCtrl.text.trim(), + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + couleur: _selectedColor, + ordreAffichage: widget.existing!.ordreAffichage, + )); + } else { + widget.bloc.add(CreateOrgTypeEvent( + code: _codeCtrl.text.trim(), + libelle: _libelleCtrl.text.trim(), + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + couleur: _selectedColor, + )); + } + } +} diff --git a/lib/features/organizations/presentation/pages/organization_detail_page.dart b/lib/features/organizations/presentation/pages/organization_detail_page.dart index 5f78953..aef0537 100644 --- a/lib/features/organizations/presentation/pages/organization_detail_page.dart +++ b/lib/features/organizations/presentation/pages/organization_detail_page.dart @@ -1,5 +1,4 @@ -/// Page de détail d'une organisation -/// Respecte strictement le design system établi dans l'application +/// Page de détail d'une organisation — affichage exhaustif de tous les champs library organisation_detail_page; import 'package:flutter/material.dart'; @@ -10,15 +9,14 @@ import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; import '../../bloc/organizations_state.dart'; import 'edit_organization_page.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; +import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../../../../features/authentication/data/models/user_role.dart'; -/// Page de détail d'une organisation avec design system cohérent class OrganizationDetailPage extends StatefulWidget { final String organizationId; - const OrganizationDetailPage({ - super.key, - required this.organizationId, - }); + const OrganizationDetailPage({super.key, required this.organizationId}); @override State createState() => _OrganizationDetailPageState(); @@ -28,758 +26,424 @@ class _OrganizationDetailPageState extends State { @override void initState() { super.initState(); - // Charger les détails de l'organisation context.read().add(LoadOrganizationById(widget.organizationId)); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), // Background cohérent + backgroundColor: AppColors.lightBackground, appBar: AppBar( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.brandGreen, foregroundColor: Colors.white, title: const Text('Détail Organisation'), elevation: 0, actions: [ - IconButton( - onPressed: () => _showEditDialog(), - icon: const Icon(Icons.edit), - tooltip: 'Modifier', - ), - PopupMenuButton( - onSelected: (value) => _handleMenuAction(value), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'activate', - child: Row( - children: [ - Icon(Icons.check_circle, color: Color(0xFF10B981)), - SizedBox(width: 8), - Text('Activer'), - ], - ), - ), - const PopupMenuItem( - value: 'deactivate', - child: Row( - children: [ - Icon(Icons.pause_circle, color: Color(0xFF6B7280)), - SizedBox(width: 8), - Text('Désactiver'), - ], - ), - ), - const PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon(Icons.delete, color: Colors.red), - SizedBox(width: 8), - Text('Supprimer'), - ], - ), - ), - ], - ), + IconButton(onPressed: _showEditPage, icon: const Icon(Icons.edit), tooltip: 'Modifier'), + Builder(builder: (ctx) { + final authState = ctx.read().state; + final isSuperAdmin = authState is AuthAuthenticated && + authState.effectiveRole == UserRole.superAdmin; + if (!isSuperAdmin) return const SizedBox.shrink(); + return PopupMenuButton( + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'activate', child: Row(children: [Icon(Icons.check_circle, color: AppColors.success), SizedBox(width: 8), Text('Activer')])), + const PopupMenuItem(value: 'deactivate', child: Row(children: [Icon(Icons.pause_circle, color: AppColors.textSecondaryLight), SizedBox(width: 8), Text('Désactiver')])), + const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: Colors.red), SizedBox(width: 8), Text('Supprimer')])), + ], + ); + }), ], ), body: BlocBuilder( builder: (context, state) { - if (state is OrganizationLoading) { - return _buildLoadingState(); - } else if (state is OrganizationLoaded) { - return _buildDetailContent(state.organization); - } else if (state is OrganizationsError) { - return _buildErrorState(state); - } - return _buildEmptyState(); + if (state is OrganizationLoading) return _buildLoading(); + if (state is OrganizationLoaded) return _buildContent(state.organization); + if (state is OrganizationsError) return _buildError(state); + return _buildEmpty(); }, ), ); } - /// État de chargement - Widget _buildLoadingState() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF6C5CE7)), - ), - SizedBox(height: 16), - Text( - 'Chargement des détails...', - style: TextStyle( - fontSize: 16, - color: Color(0xFF6B7280), - ), - ), - ], - ), - ); - } - - /// Contenu principal avec les détails - Widget _buildDetailContent(OrganizationModel organization) { + Widget _buildContent(OrganizationModel org) { return SingleChildScrollView( - padding: const EdgeInsets.all(12), // SpacingTokens cohérent + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeaderCard(organization), - const SizedBox(height: 16), - _buildInfoCard(organization), - const SizedBox(height: 16), - _buildStatsCard(organization), - const SizedBox(height: 16), - _buildContactCard(organization), - const SizedBox(height: 16), - _buildActionsCard(organization), + _buildHeaderCard(org), + const SizedBox(height: 8), + _buildInfoCard(org), + const SizedBox(height: 8), + _buildContactCard(org), + if (_hasAddress(org)) ...[const SizedBox(height: 8), _buildAddressCard(org)], + const SizedBox(height: 8), + _buildStatsCard(org), + if (_hasFinances(org)) ...[const SizedBox(height: 8), _buildFinancesCard(org)], + if (_hasMission(org)) ...[const SizedBox(height: 8), _buildMissionCard(org)], + if (_hasSupplementary(org)) ...[const SizedBox(height: 8), _buildSupplementaryCard(org)], + if (org.notes?.isNotEmpty == true) ...[const SizedBox(height: 8), _buildNotesCard(org)], + const SizedBox(height: 8), + _buildActionsCard(org), ], ), ); } - /// Carte d'en-tête avec informations principales - Widget _buildHeaderCard(OrganizationModel organization) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - const Color(0xFF6C5CE7), - const Color(0xFF6C5CE7).withOpacity(0.8), - ], - ), - borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - organization.typeOrganisation.icon, - style: const TextStyle(fontSize: 24), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - organization.nom, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - if (organization.nomCourt?.isNotEmpty == true) ...[ - const SizedBox(height: 4), - Text( - organization.nomCourt!, - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - const SizedBox(height: 8), - _buildStatusBadge(organization.statut), - ], - ), - ), - ], - ), - if (organization.description?.isNotEmpty == true) ...[ - const SizedBox(height: 16), - Text( - organization.description!, - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.9), - height: 1.4, - ), - ), - ], - ], - ), - ); - } + // ── Header ───────────────────────────────────────────────────────────────── - /// Badge de statut - Widget _buildStatusBadge(StatutOrganization statut) { - - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - statut.displayName, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ); - } - - /// Carte d'informations générales - Widget _buildInfoCard(OrganizationModel organization) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Informations générales', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - _buildInfoRow( - icon: Icons.category, - label: 'Type', - value: organization.typeOrganisation.displayName, - ), - const SizedBox(height: 12), - _buildInfoRow( - icon: Icons.location_on, - label: 'Localisation', - value: _buildLocationText(organization), - ), - const SizedBox(height: 12), - _buildInfoRow( - icon: Icons.calendar_today, - label: 'Date de création', - value: _formatDate(organization.dateCreation), - ), - if (organization.ancienneteAnnees > 0) ...[ - const SizedBox(height: 12), - _buildInfoRow( - icon: Icons.access_time, - label: 'Ancienneté', - value: '${organization.ancienneteAnnees} ans', - ), - ], - ], - ), - ); - } - - /// Ligne d'information - Widget _buildInfoRow({ - required IconData icon, - required String label, - required String value, - }) { - return Row( - children: [ - Icon( - icon, - size: 20, - color: const Color(0xFF6C5CE7), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF6B7280), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF374151), - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ); - } - - /// Carte de statistiques - Widget _buildStatsCard(OrganizationModel organization) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Statistiques', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildStatItem( - icon: Icons.people, - label: 'Membres', - value: organization.nombreMembres.toString(), - color: const Color(0xFF3B82F6), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildStatItem( - icon: Icons.event, - label: 'Événements', - value: (organization.nombreEvenements ?? 0).toString(), - color: const Color(0xFF10B981), - ), - ), - ], - ), - ], - ), - ); - } - - /// Item de statistique - Widget _buildStatItem({ - required IconData icon, - required String label, - required String value, - required Color color, - }) { + Widget _buildHeaderCard(OrganizationModel org) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: color.withOpacity(0.05), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: color.withOpacity(0.1), - width: 1, - ), + gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.brandGreen, AppColors.primaryGreen]), + borderRadius: BorderRadius.circular(10), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))], ), - child: Column( - children: [ - Icon( - icon, - size: 24, - color: color, - ), - const SizedBox(height: 8), - Text( - value, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 12, - color: color, - fontWeight: FontWeight.w500, - ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.business_outlined, size: 24, color: Colors.white), ), + const SizedBox(width: 16), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(org.nom, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)), + if (org.nomCourt?.isNotEmpty == true) ...[ + const SizedBox(height: 2), + Text(org.nomCourt!, style: TextStyle(fontSize: 13, color: Colors.white.withOpacity(0.9))), + ], + const SizedBox(height: 6), + Row(children: [ + _buildWhiteBadge(org.typeOrganisationLibelle ?? org.typeOrganisation), + const SizedBox(width: 6), + _buildWhiteBadge(org.statutLibelle ?? org.statut.displayName), + ]), + ])), + ]), + if (org.description?.isNotEmpty == true) ...[ + const SizedBox(height: 12), + Text(org.description!, style: TextStyle(fontSize: 13, color: Colors.white.withOpacity(0.9), height: 1.4)), ], - ), + const SizedBox(height: 10), + Row(children: [ + _buildBoolBadge(Icons.public, 'Public', org.organisationPublique), + const SizedBox(width: 8), + _buildBoolBadge(Icons.person_add, 'Ouvert', org.accepteNouveauxMembres), + ]), + ]), ); } - /// Carte de contact - Widget _buildContactCard(OrganizationModel organization) { + Widget _buildWhiteBadge(String text) { return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Contact', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - if (organization.email?.isNotEmpty == true) - _buildContactRow( - icon: Icons.email, - label: 'Email', - value: organization.email!, - onTap: () => _launchEmail(organization.email!), - ), - if (organization.telephone?.isNotEmpty == true) ...[ - const SizedBox(height: 12), - _buildContactRow( - icon: Icons.phone, - label: 'Téléphone', - value: organization.telephone!, - onTap: () => _launchPhone(organization.telephone!), - ), - ], - if (organization.siteWeb?.isNotEmpty == true) ...[ - const SizedBox(height: 12), - _buildContactRow( - icon: Icons.web, - label: 'Site web', - value: organization.siteWeb!, - onTap: () => _launchWebsite(organization.siteWeb!), - ), - ], - ], - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(10)), + child: Text(text, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white)), ); } - /// Ligne de contact - Widget _buildContactRow({ - required IconData icon, - required String label, - required String value, - VoidCallback? onTap, - }) { + Widget _buildBoolBadge(IconData icon, String label, bool value) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 12, color: value ? Colors.greenAccent : Colors.white60), + const SizedBox(width: 4), + Text('$label: ${value ? 'Oui' : 'Non'}', style: TextStyle(fontSize: 11, color: value ? Colors.greenAccent : Colors.white60)), + ]); + } + + // ── Informations générales ────────────────────────────────────────────────── + + Widget _buildInfoCard(OrganizationModel org) { + return _buildCard('Informations générales', Icons.info_outline, [ + if (org.dateFondation != null) _buildInfoRow(Icons.cake, 'Date de fondation', _formatDate(org.dateFondation)), + if (org.dateFondation != null) const SizedBox(height: 10), + if (org.ancienneteAnnees > 0) _buildInfoRow(Icons.access_time, 'Ancienneté', '${org.ancienneteAnnees} an(s)'), + if (org.ancienneteAnnees > 0) const SizedBox(height: 10), + if (org.numeroEnregistrement?.isNotEmpty == true) _buildInfoRow(Icons.assignment, 'N° d\'enregistrement', org.numeroEnregistrement!), + if (org.numeroEnregistrement?.isNotEmpty == true) const SizedBox(height: 10), + _buildInfoRow(Icons.calendar_today, 'Créé dans le système', _formatDate(org.dateCreation)), + if (org.dateModification != null) ...[ + const SizedBox(height: 10), + _buildInfoRow(Icons.edit_calendar, 'Dernière modification', _formatDate(org.dateModification)), + ], + ]); + } + + // ── Contact ───────────────────────────────────────────────────────────────── + + Widget _buildContactCard(OrganizationModel org) { + final hasAny = org.email?.isNotEmpty == true || + org.telephone?.isNotEmpty == true || + org.telephoneSecondaire?.isNotEmpty == true || + org.emailSecondaire?.isNotEmpty == true || + org.siteWeb?.isNotEmpty == true || + org.reseauxSociaux?.isNotEmpty == true; + if (!hasAny) return const SizedBox.shrink(); + + return _buildCard('Contact', Icons.contact_phone, [ + if (org.email?.isNotEmpty == true) _buildContactRow(Icons.email, 'Email', org.email!, onTap: () => _launchEmail(org.email!)), + if (org.emailSecondaire?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildContactRow(Icons.alternate_email, 'Email secondaire', org.emailSecondaire!, onTap: () => _launchEmail(org.emailSecondaire!))], + if (org.telephone?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildContactRow(Icons.phone, 'Téléphone', org.telephone!, onTap: () => _launchPhone(org.telephone!))], + if (org.telephoneSecondaire?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildContactRow(Icons.phone_forwarded, 'Téléphone secondaire', org.telephoneSecondaire!, onTap: () => _launchPhone(org.telephoneSecondaire!))], + if (org.siteWeb?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildContactRow(Icons.web, 'Site web', org.siteWeb!, onTap: () => _launchWebsite(org.siteWeb!))], + if (org.reseauxSociaux?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildInfoRow(Icons.share, 'Réseaux sociaux', org.reseauxSociaux!)], + ]); + } + + // ── Adresse ───────────────────────────────────────────────────────────────── + + bool _hasAddress(OrganizationModel org) => + org.adresse?.isNotEmpty == true || org.ville?.isNotEmpty == true || + org.region?.isNotEmpty == true || org.pays?.isNotEmpty == true; + + Widget _buildAddressCard(OrganizationModel org) { + return _buildCard('Localisation', Icons.location_on, [ + if (org.adresse?.isNotEmpty == true) _buildInfoRow(Icons.location_on, 'Adresse', org.adresse!), + if (org.adresse?.isNotEmpty == true && (org.ville?.isNotEmpty == true)) const SizedBox(height: 10), + if (org.ville?.isNotEmpty == true) _buildInfoRow(Icons.location_city, 'Ville', '${org.ville!}${org.codePostal?.isNotEmpty == true ? ' — ${org.codePostal}' : ''}'), + if (org.region?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildInfoRow(Icons.map, 'Région', org.region!)], + if (org.pays?.isNotEmpty == true) ...[const SizedBox(height: 10), _buildInfoRow(Icons.flag, 'Pays', org.pays!)], + ]); + } + + // ── Statistiques ───────────────────────────────────────────────────────────── + + Widget _buildStatsCard(OrganizationModel org) { + return _buildCard('Statistiques', Icons.bar_chart, [ + Row(children: [ + Expanded(child: _buildStatItem(Icons.people, 'Membres', org.nombreMembres.toString(), AppColors.primaryGreen)), + const SizedBox(width: 10), + Expanded(child: _buildStatItem(Icons.admin_panel_settings, 'Admins', org.nombreAdministrateurs.toString(), AppColors.brandGreen)), + const SizedBox(width: 10), + Expanded(child: _buildStatItem(Icons.event, 'Événements', (org.nombreEvenements ?? 0).toString(), AppColors.success)), + ]), + ]); + } + + Widget _buildStatItem(IconData icon, String label, String value, Color color) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: color.withOpacity(0.05), borderRadius: BorderRadius.circular(6), border: Border.all(color: color.withOpacity(0.15))), + child: Column(children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 4), + Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color)), + Text(label, style: TextStyle(fontSize: 11, color: color)), + ]), + ); + } + + // ── Finances ───────────────────────────────────────────────────────────────── + + bool _hasFinances(OrganizationModel org) => + org.budgetAnnuel != null || org.cotisationObligatoire || org.montantCotisationAnnuelle != null; + + Widget _buildFinancesCard(OrganizationModel org) { + return _buildCard('Finances', Icons.account_balance_wallet, [ + _buildInfoRow(Icons.currency_exchange, 'Devise', org.devise), + if (org.budgetAnnuel != null) ...[const SizedBox(height: 10), _buildInfoRow(Icons.account_balance, 'Budget annuel', '${_formatMontant(org.budgetAnnuel)} ${org.devise}')], + const SizedBox(height: 10), + _buildInfoRow(Icons.payments, 'Cotisation', org.cotisationObligatoire ? 'Obligatoire' : 'Facultative'), + if (org.cotisationObligatoire && org.montantCotisationAnnuelle != null) ...[const SizedBox(height: 10), _buildInfoRow(Icons.money, 'Montant annuel', '${_formatMontant(org.montantCotisationAnnuelle)} ${org.devise}')], + ]); + } + + // ── Mission ───────────────────────────────────────────────────────────────── + + bool _hasMission(OrganizationModel org) => + org.objectifs?.isNotEmpty == true || org.activitesPrincipales?.isNotEmpty == true; + + Widget _buildMissionCard(OrganizationModel org) { + return _buildCard('Mission & Activités', Icons.flag, [ + if (org.objectifs?.isNotEmpty == true) _buildTextBlock(Icons.track_changes, 'Objectifs', org.objectifs!), + if (org.objectifs?.isNotEmpty == true && org.activitesPrincipales?.isNotEmpty == true) const SizedBox(height: 12), + if (org.activitesPrincipales?.isNotEmpty == true) _buildTextBlock(Icons.work, 'Activités principales', org.activitesPrincipales!), + ]); + } + + // ── Infos complémentaires ──────────────────────────────────────────────────── + + bool _hasSupplementary(OrganizationModel org) => + org.certifications?.isNotEmpty == true || org.partenaires?.isNotEmpty == true; + + Widget _buildSupplementaryCard(OrganizationModel org) { + return _buildCard('Informations complémentaires', Icons.info, [ + if (org.certifications?.isNotEmpty == true) _buildTextBlock(Icons.verified, 'Certifications / Agréments', org.certifications!), + if (org.certifications?.isNotEmpty == true && org.partenaires?.isNotEmpty == true) const SizedBox(height: 12), + if (org.partenaires?.isNotEmpty == true) _buildTextBlock(Icons.handshake, 'Partenaires', org.partenaires!), + ]); + } + + // ── Notes internes ────────────────────────────────────────────────────────── + + Widget _buildNotesCard(OrganizationModel org) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFCD34D), width: 1), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Icon(Icons.sticky_note_2, size: 18, color: Color(0xFFF59E0B)), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Notes internes', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFFF59E0B))), + const SizedBox(height: 4), + Text(org.notes!, style: const TextStyle(fontSize: 13, color: AppColors.textPrimaryLight, height: 1.4)), + ])), + ]), + ); + } + + // ── Actions ───────────────────────────────────────────────────────────────── + + Widget _buildActionsCard(OrganizationModel org) { + return _buildCard('Actions', Icons.bolt, [ + Row(children: [ + Expanded(child: ElevatedButton.icon( + onPressed: _showEditPage, + 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: () => _showDeleteConfirmation(org), + icon: const Icon(Icons.delete), + label: const Text('Supprimer'), + style: OutlinedButton.styleFrom(foregroundColor: Colors.red, side: const BorderSide(color: Colors.red)), + )), + ]), + ]); + } + + // ── Widgets helpers ───────────────────────────────────────────────────────── + + Widget _buildCard(String title, IconData icon, List children) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(icon, size: 15, color: AppColors.primaryGreen), + const SizedBox(width: 6), + Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppColors.primaryGreen)), + ]), + const SizedBox(height: 10), + ...children, + ]), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 18, color: AppColors.primaryGreen), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textSecondaryLight, fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text(value, style: const TextStyle(fontSize: 13, color: AppColors.textPrimaryLight, fontWeight: FontWeight.w600)), + ])), + ]); + } + + Widget _buildTextBlock(IconData icon, String label, String value) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(icon, size: 15, color: AppColors.primaryGreen), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondaryLight, fontWeight: FontWeight.w600)), + ]), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 13, color: AppColors.textPrimaryLight, height: 1.5)), + ]); + } + + Widget _buildContactRow(IconData icon, String label, String value, {VoidCallback? onTap}) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Icon( - icon, - size: 20, - color: const Color(0xFF6C5CE7), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF6B7280), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: TextStyle( - fontSize: 14, - color: onTap != null ? const Color(0xFF6C5CE7) : const Color(0xFF374151), - fontWeight: FontWeight.w600, - decoration: onTap != null ? TextDecoration.underline : null, - ), - ), - ], - ), - ), - if (onTap != null) - const Icon( - Icons.open_in_new, - size: 16, - color: Color(0xFF6C5CE7), - ), - ], - ), + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(children: [ + Icon(icon, size: 18, color: AppColors.primaryGreen), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textSecondaryLight)), + Text(value, style: TextStyle(fontSize: 13, color: onTap != null ? AppColors.primaryGreen : AppColors.textPrimaryLight, fontWeight: FontWeight.w600, decoration: onTap != null ? TextDecoration.underline : null)), + ])), + if (onTap != null) const Icon(Icons.open_in_new, size: 14, color: AppColors.primaryGreen), + ]), ), ); } - /// Carte d'actions - Widget _buildActionsCard(OrganizationModel organization) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Actions', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF6C5CE7), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => _showEditDialog(organization), - icon: const Icon(Icons.edit), - label: const Text('Modifier'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: () => _showDeleteConfirmation(organization), - icon: const Icon(Icons.delete), - label: const Text('Supprimer'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red), - ), - ), - ), - ], - ), - ], - ), - ); - } + // ── États ──────────────────────────────────────────────────────────────────── - /// État d'erreur - Widget _buildErrorState(OrganizationsError state) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red.shade400, - ), - const SizedBox(height: 16), - Text( - 'Erreur', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.red.shade700, - ), - ), - const SizedBox(height: 8), - Text( - state.message, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF6B7280), - ), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () { - context.read().add(LoadOrganizationById(widget.organizationId)); - }, - icon: const Icon(Icons.refresh), - label: const Text('Réessayer'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, - ), - ), - ], - ), - ); - } + Widget _buildLoading() => const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(AppColors.primaryGreen)), + SizedBox(height: 16), + Text('Chargement...', style: TextStyle(color: AppColors.textSecondaryLight)), + ])); - /// État vide - Widget _buildEmptyState() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.business_outlined, - size: 64, - color: Color(0xFF6B7280), - ), - SizedBox(height: 16), - Text( - 'Organisation non trouvée', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF374151), - ), - ), - ], - ), - ); - } + Widget _buildError(OrganizationsError state) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), + const SizedBox(height: 16), + Text(state.message, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textSecondaryLight)), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context.read().add(LoadOrganizationById(widget.organizationId)), + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white), + ), + ])); - /// Construit le texte de localisation - String _buildLocationText(OrganizationModel organization) { - final parts = []; - if (organization.ville?.isNotEmpty == true) { - parts.add(organization.ville!); - } - if (organization.region?.isNotEmpty == true) { - parts.add(organization.region!); - } - if (organization.pays?.isNotEmpty == true) { - parts.add(organization.pays!); - } - return parts.isEmpty ? 'Non spécifiée' : parts.join(', '); - } + Widget _buildEmpty() => const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.business_outlined, size: 64, color: AppColors.textSecondaryLight), + SizedBox(height: 16), + Text('Organisation non trouvée', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimaryLight)), + ])); - /// Formate une date - String _formatDate(DateTime? date) { - if (date == null) return 'Non spécifiée'; - return '${date.day}/${date.month}/${date.year}'; - } + // ── Actions ────────────────────────────────────────────────────────────────── - /// Actions du menu void _handleMenuAction(String action) { switch (action) { - case 'activate': - context.read().add(ActivateOrganization(widget.organizationId)); - break; - case 'deactivate': - context.read().add(SuspendOrganization(widget.organizationId)); - break; - case 'delete': - _showDeleteConfirmation(null); - break; + case 'activate': context.read().add(ActivateOrganization(widget.organizationId)); break; + case 'deactivate': context.read().add(SuspendOrganization(widget.organizationId)); break; + case 'delete': _showDeleteConfirmation(null); break; } } - /// Ouvre la page d'édition ou le dialog selon le contexte - void _showEditDialog([OrganizationModel? organization]) { + void _showEditPage([OrganizationModel? organization]) { if (organization == null) { final state = context.read().state; - if (state is OrganizationLoaded) { - organization = state.organization; - } - } - if (organization == null || !context.mounted) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Chargement de l\'organisation en cours...')), - ); - } - return; + if (state is OrganizationLoaded) organization = state.organization; } + if (organization == null || !context.mounted) return; final org = organization; final bloc = context.read(); Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: bloc, - child: EditOrganizationPage(organization: org), - ), - ), - ).then((_) { - if (context.mounted) { - bloc.add(LoadOrganizationById(widget.organizationId)); - } - }); + MaterialPageRoute(builder: (_) => BlocProvider.value(value: bloc, child: EditOrganizationPage(organization: org))), + ).then((_) { if (context.mounted) bloc.add(LoadOrganizationById(widget.organizationId)); }); } - /// Affiche la confirmation de suppression void _showDeleteConfirmation(OrganizationModel? organization) { + final bloc = context.read(); + final nav = Navigator.of(context); showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Confirmer la suppression'), - content: Text( - organization != null - ? 'Êtes-vous sûr de vouloir supprimer "${organization.nom}" ?' - : 'Êtes-vous sûr de vouloir supprimer cette organisation ?', - ), + content: Text(organization != null ? 'Supprimer "${organization.nom}" ?' : 'Supprimer cette organisation ?'), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Annuler'), - ), + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Annuler')), ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - context.read().add(DeleteOrganization(widget.organizationId)); - Navigator.of(context).pop(); // Retour à la liste - }, + onPressed: () { Navigator.of(ctx).pop(); bloc.add(DeleteOrganization(widget.organizationId)); nav.pop(); }, style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('Supprimer', style: TextStyle(color: Colors.white)), ), @@ -788,45 +452,32 @@ class _OrganizationDetailPageState extends State { ); } - /// Lance l'application email + // ── Helpers ────────────────────────────────────────────────────────────────── + + String _formatDate(DateTime? date) { + if (date == null) return '—'; + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + String _formatMontant(double? v) { + if (v == null) return '0'; + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)} M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)} K'; + return v.toStringAsFixed(0); + } + Future _launchEmail(String email) async { final uri = Uri(scheme: 'mailto', path: email); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible d\'ouvrir l\'email: $email')), - ); - } - } + if (await canLaunchUrl(uri)) await launchUrl(uri); } - /// Lance l'application téléphone Future _launchPhone(String phone) async { final uri = Uri(scheme: 'tel', path: phone); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible d\'appeler: $phone')), - ); - } - } + if (await canLaunchUrl(uri)) await launchUrl(uri); } - /// Lance le navigateur web Future _launchWebsite(String url) async { final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url'); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible d\'ouvrir: $url')), - ); - } - } + if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication); } } diff --git a/lib/features/organizations/presentation/pages/organizations_page.dart b/lib/features/organizations/presentation/pages/organizations_page.dart index 498f1cf..cad9373 100644 --- a/lib/features/organizations/presentation/pages/organizations_page.dart +++ b/lib/features/organizations/presentation/pages/organizations_page.dart @@ -8,13 +8,14 @@ import '../widgets/organization_card.dart'; import '../widgets/create_organization_dialog.dart'; import '../widgets/edit_organization_dialog.dart'; import 'organization_detail_page.dart'; +import 'org_types_page.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; -import '../../../../shared/design_system/tokens/unionflow_colors.dart'; -import '../../../../shared/design_system/unionflow_design_v2.dart'; import '../../../../shared/design_system/components/animated_fade_in.dart'; import '../../../../shared/design_system/components/animated_slide_in.dart'; import '../../../../shared/design_system/components/african_pattern_background.dart'; import '../../../../shared/design_system/components/uf_app_bar.dart'; +import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; +import '../../../../features/authentication/data/models/user_role.dart'; /// Page de gestion des organisations - Interface sophistiquée et exhaustive /// @@ -36,7 +37,7 @@ class _OrganizationsPageState extends State with TickerProvid final TextEditingController _searchController = TextEditingController(); TabController? _tabController; final ScrollController _scrollController = ScrollController(); - List _availableTypes = []; + List _availableTypes = []; @override void initState() { @@ -62,21 +63,21 @@ class _OrganizationsPageState extends State with TickerProvid } /// Calcule les types d'organisations disponibles dans les données - List _calculateAvailableTypes(List organizations) { + List _calculateAvailableTypes(List organizations) { if (organizations.isEmpty) { return [null]; // Seulement "Toutes" } // Extraire tous les types uniques final typesSet = organizations.map((org) => org.typeOrganisation).toSet(); - final types = typesSet.toList()..sort((a, b) => a.displayName.compareTo(b.displayName)); + final types = typesSet.toList()..sort((a, b) => a.compareTo(b)); // null en premier pour "Toutes", puis les types triés alphabétiquement return [null, ...types]; } /// Initialise ou met à jour le TabController si les types ont changé - void _updateTabController(List newTypes) { + void _updateTabController(List newTypes) { if (_availableTypes.length != newTypes.length || !_availableTypes.every((type) => newTypes.contains(type))) { _availableTypes = newTypes; @@ -94,7 +95,7 @@ class _OrganizationsPageState extends State with TickerProvid ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), - backgroundColor: UnionFlowColors.error, + backgroundColor: AppColors.error, duration: const Duration(seconds: 4), action: SnackBarAction( label: 'Réessayer', @@ -109,7 +110,7 @@ class _OrganizationsPageState extends State with TickerProvid ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Organisation créée avec succès'), - backgroundColor: UnionFlowColors.success, + backgroundColor: AppColors.success, duration: Duration(seconds: 2), ), ); @@ -117,7 +118,7 @@ class _OrganizationsPageState extends State with TickerProvid ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Organisation mise à jour avec succès'), - backgroundColor: UnionFlowColors.success, + backgroundColor: AppColors.success, duration: Duration(seconds: 2), ), ); @@ -125,7 +126,7 @@ class _OrganizationsPageState extends State with TickerProvid ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Organisation supprimée avec succès'), - backgroundColor: UnionFlowColors.success, + backgroundColor: AppColors.success, duration: Duration(seconds: 2), ), ); @@ -137,10 +138,21 @@ class _OrganizationsPageState extends State with TickerProvid backgroundColor: Colors.transparent, appBar: UFAppBar( title: 'Gestion des Organisations', - backgroundColor: UnionFlowColors.surface, - foregroundColor: UnionFlowColors.textPrimary, + backgroundColor: AppColors.lightSurface, + foregroundColor: AppColors.textPrimaryLight, elevation: 0, actions: [ + IconButton( + icon: const Icon(Icons.category_outlined), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const OrgTypesPage(), + ), + ); + }, + tooltip: 'Types d\'organisations', + ), IconButton( icon: const Icon(Icons.refresh), onPressed: () { @@ -246,7 +258,7 @@ class _OrganizationsPageState extends State with TickerProvid padding: EdgeInsets.all(SpacingTokens.md), child: Center( child: CircularProgressIndicator( - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, ), ), ), @@ -287,10 +299,16 @@ class _OrganizationsPageState extends State with TickerProvid if (state is! OrganizationsLoaded && state is! OrganizationsLoadingMore) { return null; } + // Réservé au Super Admin uniquement + final authState = context.read().state; + if (authState is! AuthAuthenticated || + authState.effectiveRole != UserRole.superAdmin) { + return null; + } return FloatingActionButton.extended( onPressed: _showCreateOrganizationDialog, - backgroundColor: UnionFlowColors.unionGreen, + backgroundColor: AppColors.primaryGreen, elevation: 8, icon: const Icon(Icons.add, color: Colors.white), label: const Text( @@ -308,9 +326,19 @@ class _OrganizationsPageState extends State with TickerProvid return Container( padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( - gradient: UnionFlowColors.primaryGradient, + gradient: const LinearGradient( + colors: [AppColors.brandGreen, AppColors.primaryGreen], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(RadiusTokens.lg), - boxShadow: UnionFlowColors.greenGlowShadow, + boxShadow: [ + BoxShadow( + color: AppColors.primaryGreen.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: Row( children: [ @@ -334,7 +362,7 @@ class _OrganizationsPageState extends State with TickerProvid const Text( 'Gestion des Organisations', style: TextStyle( - fontSize: 20, + fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), @@ -370,9 +398,9 @@ class _OrganizationsPageState extends State with TickerProvid return Container( padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: AppColors.lightSurface, borderRadius: BorderRadius.circular(RadiusTokens.lg), - boxShadow: UnionFlowColors.softShadow, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2))], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -381,16 +409,16 @@ class _OrganizationsPageState extends State with TickerProvid children: [ const Icon( Icons.analytics_outlined, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, size: 20, ), const SizedBox(width: SpacingTokens.xs), const Text( 'Statistiques', style: TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), ], @@ -403,7 +431,7 @@ class _OrganizationsPageState extends State with TickerProvid 'Total', totalOrgs.toString(), Icons.business_outlined, - UnionFlowColors.unionGreen, + AppColors.primaryGreen, ), ), const SizedBox(width: SpacingTokens.sm), @@ -412,7 +440,7 @@ class _OrganizationsPageState extends State with TickerProvid 'Actives', activeOrgs.toString(), Icons.check_circle_outline, - UnionFlowColors.success, + AppColors.success, ), ), const SizedBox(width: SpacingTokens.sm), @@ -421,7 +449,7 @@ class _OrganizationsPageState extends State with TickerProvid 'Membres', totalMembers.toString(), Icons.people_outline, - UnionFlowColors.info, + AppColors.primaryGreen, ), ), ], @@ -452,14 +480,14 @@ class _OrganizationsPageState extends State with TickerProvid style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), Text( label, style: const TextStyle( fontSize: 12, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, fontWeight: FontWeight.w500, ), ), @@ -473,16 +501,16 @@ class _OrganizationsPageState extends State with TickerProvid return Container( padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: AppColors.lightSurface, borderRadius: BorderRadius.circular(RadiusTokens.lg), - boxShadow: UnionFlowColors.softShadow, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2))], ), child: Container( decoration: BoxDecoration( - color: UnionFlowColors.surfaceVariant, + color: AppColors.lightBackground, borderRadius: BorderRadius.circular(RadiusTokens.md), border: Border.all( - color: UnionFlowColors.border, + color: AppColors.lightBorder, width: 1, ), ), @@ -494,17 +522,17 @@ class _OrganizationsPageState extends State with TickerProvid decoration: InputDecoration( hintText: 'Rechercher par nom, type, localisation...', hintStyle: const TextStyle( - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, fontSize: 14, ), - prefixIcon: const Icon(Icons.search, color: UnionFlowColors.unionGreen), + prefixIcon: const Icon(Icons.search, color: AppColors.primaryGreen), suffixIcon: _searchController.text.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); context.read().add(const SearchOrganizations('')); }, - icon: const Icon(Icons.clear, color: UnionFlowColors.textSecondary), + icon: const Icon(Icons.clear, color: AppColors.textSecondaryLight), ) : null, border: InputBorder.none, @@ -519,7 +547,7 @@ class _OrganizationsPageState extends State with TickerProvid } /// Onglets de catégories générés dynamiquement selon les types disponibles - Widget _buildCategoryTabs(List availableTypes) { + Widget _buildCategoryTabs(List availableTypes) { if (_tabController == null || availableTypes.isEmpty) { return const SizedBox.shrink(); } @@ -528,16 +556,16 @@ class _OrganizationsPageState extends State with TickerProvid builder: (context, state) { return Container( decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: AppColors.lightSurface, borderRadius: BorderRadius.circular(RadiusTokens.lg), - boxShadow: UnionFlowColors.softShadow, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2))], ), child: TabBar( controller: _tabController!, isScrollable: availableTypes.length > 4, // Scrollable si plus de 4 types - labelColor: UnionFlowColors.unionGreen, - unselectedLabelColor: UnionFlowColors.textSecondary, - indicatorColor: UnionFlowColors.unionGreen, + labelColor: AppColors.primaryGreen, + unselectedLabelColor: AppColors.textSecondaryLight, + indicatorColor: AppColors.primaryGreen, indicatorWeight: 3, indicatorSize: TabBarIndicatorSize.tab, labelStyle: const TextStyle( @@ -560,22 +588,8 @@ class _OrganizationsPageState extends State with TickerProvid } }, tabs: availableTypes.map((type) { - // null = "Toutes", sinon utiliser le displayName du type - final label = type == null ? 'Toutes' : type.displayName; - final icon = type?.icon; // Emoji du type - - return Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Text(icon, style: const TextStyle(fontSize: 16)), - const SizedBox(width: SpacingTokens.xs), - ], - Text(label), - ], - ), - ); + final label = type == null ? 'Toutes' : type; + return Tab(text: label); }).toList(), ), ); @@ -623,13 +637,13 @@ class _OrganizationsPageState extends State with TickerProvid Container( padding: const EdgeInsets.all(SpacingTokens.xl), decoration: BoxDecoration( - color: UnionFlowColors.unionGreenPale, + color: AppColors.primaryGreen.withOpacity(0.12), shape: BoxShape.circle, ), child: const Icon( Icons.business_outlined, size: 64, - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, ), ), const SizedBox(height: SpacingTokens.lg), @@ -638,7 +652,7 @@ class _OrganizationsPageState extends State with TickerProvid style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), ), const SizedBox(height: SpacingTokens.xs), @@ -646,7 +660,7 @@ class _OrganizationsPageState extends State with TickerProvid 'Essayez de modifier vos critères de recherche\nou créez une nouvelle organisation', style: TextStyle( fontSize: 14, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), textAlign: TextAlign.center, ), @@ -659,7 +673,7 @@ class _OrganizationsPageState extends State with TickerProvid icon: const Icon(Icons.clear_all), label: const Text('Réinitialiser les filtres'), style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.unionGreen, + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.lg, @@ -683,7 +697,7 @@ class _OrganizationsPageState extends State with TickerProvid mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator( - color: UnionFlowColors.unionGreen, + color: AppColors.primaryGreen, strokeWidth: 3, ), const SizedBox(height: SpacingTokens.md), @@ -691,7 +705,7 @@ class _OrganizationsPageState extends State with TickerProvid 'Chargement des organisations...', style: TextStyle( fontSize: 14, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), ), ], @@ -706,10 +720,10 @@ class _OrganizationsPageState extends State with TickerProvid margin: const EdgeInsets.all(SpacingTokens.xl), padding: const EdgeInsets.all(SpacingTokens.xl), decoration: BoxDecoration( - color: UnionFlowColors.errorPale, + color: AppColors.error.withOpacity(0.08), borderRadius: BorderRadius.circular(RadiusTokens.lg), border: Border.all( - color: UnionFlowColors.errorLight, + color: AppColors.error.withOpacity(0.3), width: 1, ), ), @@ -719,7 +733,7 @@ class _OrganizationsPageState extends State with TickerProvid const Icon( Icons.error_outline, size: 64, - color: UnionFlowColors.error, + color: AppColors.error, ), const SizedBox(height: SpacingTokens.md), Text( @@ -727,7 +741,7 @@ class _OrganizationsPageState extends State with TickerProvid style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, + color: AppColors.textPrimaryLight, ), textAlign: TextAlign.center, ), @@ -737,7 +751,7 @@ class _OrganizationsPageState extends State with TickerProvid state.details!, style: const TextStyle( fontSize: 12, - color: UnionFlowColors.textSecondary, + color: AppColors.textSecondaryLight, ), textAlign: TextAlign.center, ), @@ -750,7 +764,7 @@ class _OrganizationsPageState extends State with TickerProvid icon: const Icon(Icons.refresh), label: const Text('Réessayer'), style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.unionGreen, + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.lg, @@ -771,10 +785,11 @@ class _OrganizationsPageState extends State with TickerProvid void _showOrganizationDetails(OrganizationModel org) { final orgId = org.id; if (orgId == null || orgId.isEmpty) return; + final bloc = context.read(); Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), + builder: (_) => BlocProvider.value( + value: bloc, child: OrganizationDetailPage(organizationId: orgId), ), ), @@ -782,39 +797,48 @@ class _OrganizationsPageState extends State with TickerProvid } void _showCreateOrganizationDialog() { + final bloc = context.read(); showDialog( context: context, - builder: (context) => const CreateOrganizationDialog(), + builder: (_) => BlocProvider.value( + value: bloc, + child: const CreateOrganizationDialog(), + ), ); } void _showEditOrganizationDialog(OrganizationModel org) { + final bloc = context.read(); showDialog( context: context, - builder: (context) => EditOrganizationDialog(organization: org), + builder: (_) => BlocProvider.value( + value: bloc, + child: EditOrganizationDialog(organization: org), + ), ); } void _confirmDeleteOrganization(OrganizationModel org) { + final bloc = context.read(); showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Supprimer l\'organisation'), content: Text('Voulez-vous vraiment supprimer "${org.nom}" ?'), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Annuler'), ), ElevatedButton( onPressed: () { if (org.id != null) { - context.read().add(DeleteOrganization(org.id!)); + bloc.add(DeleteOrganization(org.id!)); } - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(); }, style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.error, + backgroundColor: AppColors.error, foregroundColor: Colors.white, ), child: const Text('Supprimer'), diff --git a/lib/features/organizations/presentation/widgets/create_organization_dialog.dart b/lib/features/organizations/presentation/widgets/create_organization_dialog.dart index 21693f9..b85d150 100644 --- a/lib/features/organizations/presentation/widgets/create_organization_dialog.dart +++ b/lib/features/organizations/presentation/widgets/create_organization_dialog.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; +import '../../bloc/org_types_bloc.dart'; +import '../../domain/entities/type_reference_entity.dart'; import '../../data/models/organization_model.dart'; +import '../../../../core/di/injection_container.dart'; /// Dialogue de création d'organisation class CreateOrganizationDialog extends StatefulWidget { @@ -34,12 +37,20 @@ class _CreateOrganizationDialogState extends State { final _objectifsController = TextEditingController(); // Valeurs sélectionnées - TypeOrganization _selectedType = TypeOrganization.association; + String? _selectedTypeCode; bool _accepteNouveauxMembres = true; + late final OrgTypesBloc _orgTypesBloc; bool _organisationPublique = true; + @override + void initState() { + super.initState(); + _orgTypesBloc = sl()..add(const LoadOrgTypes()); + } + @override void dispose() { + _orgTypesBloc.close(); _nomController.dispose(); _nomCourtController.dispose(); _descriptionController.dispose(); @@ -146,24 +157,39 @@ class _CreateOrganizationDialogState extends State { ), const SizedBox(height: 12), - // Type d'organisation - DropdownButtonFormField( - value: _selectedType, - decoration: const InputDecoration( - labelText: 'Type d\'organisation *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: TypeOrganization.values.map((type) { - return DropdownMenuItem( - value: type, - child: Text(type.displayName), + // Type d'organisation dynamique + BlocBuilder( + bloc: _orgTypesBloc, + builder: (context, orgTypesState) { + final types = orgTypesState is OrgTypesLoaded + ? orgTypesState.types + : orgTypesState is OrgTypeSuccess + ? orgTypesState.types + : []; + if (orgTypesState is OrgTypesLoading || orgTypesState is OrgTypesInitial) { + return const InputDecorator( + decoration: InputDecoration( + labelText: 'Type d\'organisation *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + child: LinearProgressIndicator(), + ); + } + return DropdownButtonFormField( + value: types.any((t) => t.code == _selectedTypeCode) ? _selectedTypeCode : null, + decoration: const InputDecoration( + labelText: 'Type d\'organisation *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: types.map((type) => DropdownMenuItem( + value: type.code, + child: Text(type.libelle), + )).toList(), + onChanged: (value) => setState(() => _selectedTypeCode = value), + validator: (value) => value == null ? 'Le type est obligatoire' : null, ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedType = value!; - }); }, ), const SizedBox(height: 16), @@ -378,7 +404,7 @@ class _CreateOrganizationDialogState extends State { pays: _paysController.text.isNotEmpty ? _paysController.text : null, siteWeb: _siteWebController.text.isNotEmpty ? _siteWebController.text : null, objectifs: _objectifsController.text.isNotEmpty ? _objectifsController.text : null, - typeOrganisation: _selectedType, + typeOrganisation: _selectedTypeCode ?? 'ASSOCIATION', statut: StatutOrganization.active, accepteNouveauxMembres: _accepteNouveauxMembres, organisationPublique: _organisationPublique, diff --git a/lib/features/organizations/presentation/widgets/edit_organization_dialog.dart b/lib/features/organizations/presentation/widgets/edit_organization_dialog.dart index 691f446..5f7c495 100644 --- a/lib/features/organizations/presentation/widgets/edit_organization_dialog.dart +++ b/lib/features/organizations/presentation/widgets/edit_organization_dialog.dart @@ -5,7 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/organizations_bloc.dart'; import '../../bloc/organizations_event.dart'; +import '../../bloc/organizations_state.dart'; +import '../../bloc/org_types_bloc.dart'; +import '../../domain/entities/type_reference_entity.dart'; import '../../data/models/organization_model.dart'; +import '../../../../core/di/injection_container.dart'; class EditOrganizationDialog extends StatefulWidget { final OrganizationModel organization; @@ -35,8 +39,10 @@ class _EditOrganizationDialogState extends State { late final TextEditingController _siteWebController; late final TextEditingController _objectifsController; - late TypeOrganization _selectedType; + late String _selectedTypeCode; late StatutOrganization _selectedStatut; + late final OrgTypesBloc _orgTypesBloc; + late final OrganizationsBloc _detailBloc; late bool _accepteNouveauxMembres; late bool _organisationPublique; @@ -57,14 +63,44 @@ class _EditOrganizationDialogState extends State { _siteWebController = TextEditingController(text: widget.organization.siteWeb ?? ''); _objectifsController = TextEditingController(text: widget.organization.objectifs ?? ''); - _selectedType = widget.organization.typeOrganisation; + _selectedTypeCode = widget.organization.typeOrganisation; _selectedStatut = widget.organization.statut; + _orgTypesBloc = sl()..add(const LoadOrgTypes()); _accepteNouveauxMembres = widget.organization.accepteNouveauxMembres; _organisationPublique = widget.organization.organisationPublique; + + // Charge le détail complet depuis l'API (la liste retourne un DTO allégé) + _detailBloc = sl(); + if (widget.organization.id != null) { + _detailBloc.add(LoadOrganizationById(widget.organization.id!)); + } + } + + void _refillForm(OrganizationModel org) { + _nomController.text = org.nom; + _nomCourtController.text = org.nomCourt ?? ''; + _descriptionController.text = org.description ?? ''; + _emailController.text = org.email ?? ''; + _telephoneController.text = org.telephone ?? ''; + _siteWebController.text = org.siteWeb ?? ''; + _adresseController.text = org.adresse ?? ''; + _villeController.text = org.ville ?? ''; + _codePostalController.text = org.codePostal ?? ''; + _regionController.text = org.region ?? ''; + _paysController.text = org.pays ?? ''; + _objectifsController.text = org.objectifs ?? ''; + setState(() { + _selectedTypeCode = org.typeOrganisation; + _selectedStatut = org.statut; + _accepteNouveauxMembres = org.accepteNouveauxMembres; + _organisationPublique = org.organisationPublique; + }); } @override void dispose() { + _orgTypesBloc.close(); + _detailBloc.close(); _nomController.dispose(); _nomCourtController.dispose(); _descriptionController.dispose(); @@ -82,8 +118,15 @@ class _EditOrganizationDialogState extends State { @override Widget build(BuildContext context) { - return Dialog( - child: Container( + return BlocListener( + bloc: _detailBloc, + listener: (context, state) { + if (state is OrganizationLoaded) { + _refillForm(state.organization); + } + }, + child: Dialog( + child: Container( width: MediaQuery.of(context).size.width * 0.9, constraints: const BoxConstraints(maxHeight: 600), child: Column( @@ -149,6 +192,7 @@ class _EditOrganizationDialogState extends State { ], ), ), + ), ); } @@ -237,23 +281,40 @@ class _EditOrganizationDialogState extends State { } Widget _buildTypeDropdown() { - return DropdownButtonFormField( - value: _selectedType, - decoration: const InputDecoration( - labelText: 'Type d\'organisation *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: TypeOrganization.values.map((type) { - return DropdownMenuItem( - value: type, - child: Text(type.displayName), + return BlocBuilder( + bloc: _orgTypesBloc, + builder: (context, orgTypesState) { + final types = orgTypesState is OrgTypesLoaded + ? orgTypesState.types + : orgTypesState is OrgTypeSuccess + ? orgTypesState.types + : []; + if (orgTypesState is OrgTypesLoading || orgTypesState is OrgTypesInitial) { + return const InputDecorator( + decoration: InputDecoration( + labelText: 'Type d\'organisation *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + child: LinearProgressIndicator(), + ); + } + return DropdownButtonFormField( + value: types.any((t) => t.code == _selectedTypeCode) ? _selectedTypeCode : null, + decoration: const InputDecoration( + labelText: 'Type d\'organisation *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: types.map((type) => DropdownMenuItem( + value: type.code, + child: Text(type.libelle), + )).toList(), + onChanged: (value) { + if (value != null) setState(() => _selectedTypeCode = value); + }, + validator: (value) => value == null ? 'Le type est obligatoire' : null, ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedType = value!; - }); }, ); } @@ -453,7 +514,7 @@ class _EditOrganizationDialogState extends State { pays: _paysController.text.isNotEmpty ? _paysController.text : null, siteWeb: _siteWebController.text.isNotEmpty ? _siteWebController.text : null, objectifs: _objectifsController.text.isNotEmpty ? _objectifsController.text : null, - typeOrganisation: _selectedType, + typeOrganisation: _selectedTypeCode, statut: _selectedStatut, accepteNouveauxMembres: _accepteNouveauxMembres, organisationPublique: _organisationPublique, diff --git a/lib/features/organizations/presentation/widgets/organization_card.dart b/lib/features/organizations/presentation/widgets/organization_card.dart index 23afcdd..9b00683 100644 --- a/lib/features/organizations/presentation/widgets/organization_card.dart +++ b/lib/features/organizations/presentation/widgets/organization_card.dart @@ -68,10 +68,7 @@ class OrganizationCard extends StatelessWidget { color: const Color(0xFF6C5CE7).withOpacity(0.1), // ColorTokens cohérent borderRadius: BorderRadius.circular(6), ), - child: Text( - organization.typeOrganisation.icon, - style: const TextStyle(fontSize: 16), - ), + child: const Icon(Icons.business_outlined, size: 18, color: Color(0xFF6C5CE7)), ), const SizedBox(width: 12), // Nom et nom court @@ -144,7 +141,7 @@ class OrganizationCard extends StatelessWidget { ), const SizedBox(width: 6), Text( - organization.typeOrganisation.displayName, + organization.typeOrganisation, style: const TextStyle( fontSize: 12, color: Color(0xFF6B7280), diff --git a/lib/features/organizations/presentation/widgets/organization_filter_widget.dart b/lib/features/organizations/presentation/widgets/organization_filter_widget.dart index 335dcd3..09fb30a 100644 --- a/lib/features/organizations/presentation/widgets/organization_filter_widget.dart +++ b/lib/features/organizations/presentation/widgets/organization_filter_widget.dart @@ -169,7 +169,7 @@ class OrganizationFilterWidget extends StatelessWidget { borderRadius: BorderRadius.circular(6), ), child: DropdownButtonHideUnderline( - child: DropdownButton( + child: DropdownButton( value: state.typeFilter, hint: const Text( 'Type', @@ -185,30 +185,17 @@ class OrganizationFilterWidget extends StatelessWidget { color: Color(0xFF374151), ), items: [ - const DropdownMenuItem( + const DropdownMenuItem( value: null, child: Text('Tous les types'), ), - ...TypeOrganization.values.map((type) { - return DropdownMenuItem( - value: type, - child: Row( - children: [ - Text( - type.icon, - style: const TextStyle(fontSize: 12), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - type.displayName, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - }), + ...state.organizations + .map((org) => org.typeOrganisation) + .toSet() + .map((code) => DropdownMenuItem( + value: code, + child: Text(code, overflow: TextOverflow.ellipsis), + )), ], onChanged: (value) { context.read().add( diff --git a/lib/features/profile/data/repositories/profile_repository.dart b/lib/features/profile/data/repositories/profile_repository.dart index 6da3fe1..e4b013d 100644 --- a/lib/features/profile/data/repositories/profile_repository.dart +++ b/lib/features/profile/data/repositories/profile_repository.dart @@ -29,20 +29,74 @@ class ProfileRepositoryImpl implements IProfileRepository { } return null; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return null; rethrow; } } - /// Adapte les clés backend (MembreResponse) vers le modèle mobile si besoin + /// Adapte les clés backend (MembreResponse) vers le modèle mobile void _normalizeMembreResponse(Map data) { + // photoUrl → photo if (data.containsKey('photoUrl') && !data.containsKey('photo')) { data['photo'] = data['photoUrl']; } + + // associationNom → organisationNom if (data.containsKey('associationNom') && !data.containsKey('organisationNom')) { data['organisationNom'] = data['associationNom']; } - if (data['id'] is String == false && data['id'] != null) { + + // statutCompte → statut (avec mapping des valeurs) + if (data.containsKey('statutCompte') && !data.containsKey('statut')) { + final sc = (data['statutCompte'] as String? ?? '').toUpperCase(); + if (sc == 'ACTIF') { + data['statut'] = 'ACTIF'; + } else if (sc == 'INACTIF') { + data['statut'] = 'INACTIF'; + } else if (sc == 'SUSPENDU') { + data['statut'] = 'SUSPENDU'; + } else { + // EN_ATTENTE_VALIDATION, EN_ATTENTE, etc. + data['statut'] = 'EN_ATTENTE'; + } + } + + // roles (List) → role (premier rôle) + if (data.containsKey('roles') && !data.containsKey('role')) { + final roles = data['roles']; + if (roles is List && roles.isNotEmpty) { + data['role'] = roles.first?.toString(); + } + } + + // Dates LocalDate [year, month, day] → ISO string "YYYY-MM-DD" + for (final field in ['dateNaissance', 'dateAdhesion', 'dateFinAdhesion', 'dateVerificationIdentite']) { + final val = data[field]; + if (val is List && val.length >= 3) { + final y = val[0].toString().padLeft(4, '0'); + final m = val[1].toString().padLeft(2, '0'); + final d = val[2].toString().padLeft(2, '0'); + data[field] = '$y-$m-$d'; + } + } + + // Dates LocalDateTime [year, month, day, h, min, s, ns] → ISO string + for (final field in ['dateCreation', 'dateModification', 'derniereActivite']) { + final val = data[field]; + if (val is List && val.length >= 3) { + final y = val[0].toString().padLeft(4, '0'); + final m = val[1].toString().padLeft(2, '0'); + final d = val[2].toString().padLeft(2, '0'); + final h = val.length > 3 ? val[3].toString().padLeft(2, '0') : '00'; + final min = val.length > 4 ? val[4].toString().padLeft(2, '0') : '00'; + final s = val.length > 5 ? val[5].toString().padLeft(2, '0') : '00'; + data[field] = '$y-$m-${d}T$h:$min:$s'; + } + } + + // id UUID → String + if (data['id'] != null && data['id'] is! String) { data['id'] = data['id'].toString(); } } @@ -77,6 +131,7 @@ class ProfileRepositoryImpl implements IProfileRepository { } return null; } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 404) return null; rethrow; } @@ -110,6 +165,7 @@ class ProfileRepositoryImpl implements IProfileRepository { final updated = membre.copyWith(photo: photoUrl); return updateProfile(id, updated); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; throw Exception('Erreur réseau lors de la mise à jour de la photo: ${e.message}'); } catch (e) { throw Exception('Erreur lors de la mise à jour de la photo: $e'); @@ -135,6 +191,7 @@ class ProfileRepositoryImpl implements IProfileRepository { throw Exception(errorMsg); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 400) { throw Exception('Ancien mot de passe incorrect'); } else if (e.response?.statusCode == 401) { @@ -161,6 +218,7 @@ class ProfileRepositoryImpl implements IProfileRepository { throw Exception('Erreur lors de la mise à jour des préférences: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; // Si l'endpoint n'existe pas (404), on sauvegarde localement via SharedPreferences if (e.response?.statusCode == 404) { // Fallback: stockage local uniquement @@ -182,6 +240,7 @@ class ProfileRepositoryImpl implements IProfileRepository { throw Exception('Erreur lors de la suppression du compte: ${response.statusCode}'); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) rethrow; if (e.response?.statusCode == 403) { throw Exception('Vous n\'avez pas les permissions pour supprimer ce compte'); } else if (e.response?.statusCode == 404) { diff --git a/lib/features/profile/presentation/bloc/profile_bloc.dart b/lib/features/profile/presentation/bloc/profile_bloc.dart index af89466..c649756 100644 --- a/lib/features/profile/presentation/bloc/profile_bloc.dart +++ b/lib/features/profile/presentation/bloc/profile_bloc.dart @@ -28,6 +28,8 @@ class ProfileBloc extends Bloc { on(_onLoadMe); on(_onLoadMyProfile); on(_onUpdateMyProfile); + on(_onChangePassword); + on(_onDeleteAccount); } /// Charge le profil du membre connecté @@ -44,8 +46,10 @@ class ProfileBloc extends Bloc { emit(const ProfileNotFound()); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; emit(ProfileError(_networkErrorMessage(e))); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ProfileError('Erreur lors du chargement du profil : $e')); } } @@ -65,8 +69,10 @@ class ProfileBloc extends Bloc { emit(const ProfileNotFound()); } } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; emit(ProfileError(_networkErrorMessage(e))); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ProfileError('Erreur lors du chargement du profil : $e')); } } @@ -84,15 +90,57 @@ class ProfileBloc extends Bloc { final updated = await _updateProfile(event.membreId, event.membre); emit(ProfileUpdated(updated)); } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; if (currentState is ProfileLoaded) { emit(ProfileLoaded(currentState.membre)); } emit(ProfileError(_networkErrorMessage(e))); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ProfileError('Erreur lors de la mise à jour du profil : $e')); } } + /// Change le mot de passe via Keycloak + Future _onChangePassword( + ChangePassword event, + Emitter emit, + ) async { + final previousState = state; + try { + emit(const PasswordChanging()); + await _repository.changePassword(event.membreId, event.oldPassword, event.newPassword); + emit(const PasswordChanged()); + if (previousState is ProfileLoaded) emit(previousState); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(ProfileError(_networkErrorMessage(e))); + if (previousState is ProfileLoaded) emit(previousState); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(ProfileError(e.toString().replaceFirst('Exception: ', ''))); + if (previousState is ProfileLoaded) emit(previousState); + } + } + + /// Supprime le compte (soft delete) + Future _onDeleteAccount( + DeleteAccount event, + Emitter emit, + ) async { + try { + emit(const AccountDeleting()); + await _repository.deleteAccount(event.membreId); + emit(const AccountDeleted()); + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) return; + emit(ProfileError(_networkErrorMessage(e))); + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; + emit(ProfileError(e.toString().replaceFirst('Exception: ', ''))); + } + } + String _networkErrorMessage(DioException e) { switch (e.type) { case DioExceptionType.connectionTimeout: diff --git a/lib/features/profile/presentation/bloc/profile_event.dart b/lib/features/profile/presentation/bloc/profile_event.dart index 0183c10..ac8e0a4 100644 --- a/lib/features/profile/presentation/bloc/profile_event.dart +++ b/lib/features/profile/presentation/bloc/profile_event.dart @@ -30,3 +30,27 @@ class UpdateMyProfile extends ProfileEvent { @override List get props => [membreId, membre]; } + +/// Change le mot de passe via Keycloak +class ChangePassword extends ProfileEvent { + final String membreId; + final String oldPassword; + final String newPassword; + const ChangePassword({ + required this.membreId, + required this.oldPassword, + required this.newPassword, + }); + + @override + List get props => [membreId, oldPassword, newPassword]; +} + +/// Supprime le compte (soft delete backend) +class DeleteAccount extends ProfileEvent { + final String membreId; + const DeleteAccount(this.membreId); + + @override + List get props => [membreId]; +} diff --git a/lib/features/profile/presentation/bloc/profile_state.dart b/lib/features/profile/presentation/bloc/profile_state.dart index ac5610b..3bc2050 100644 --- a/lib/features/profile/presentation/bloc/profile_state.dart +++ b/lib/features/profile/presentation/bloc/profile_state.dart @@ -50,3 +50,19 @@ class ProfileError extends ProfileState { class ProfileNotFound extends ProfileState { const ProfileNotFound(); } + +class PasswordChanging extends ProfileState { + const PasswordChanging(); +} + +class PasswordChanged extends ProfileState { + const PasswordChanged(); +} + +class AccountDeleting extends ProfileState { + const AccountDeleting(); +} + +class AccountDeleted extends ProfileState { + const AccountDeleted(); +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index ae7c978..55e805c 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -1,13 +1,22 @@ +import 'dart:io' show Platform, Directory, File; + +import 'package:flutter/foundation.dart' show kIsWeb; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../../../shared/widgets/core_card.dart'; import '../../../../shared/widgets/info_badge.dart'; import '../../../../shared/widgets/mini_avatar.dart'; import '../../../../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 '../../../settings/presentation/pages/language_settings_page.dart'; import '../../../settings/presentation/pages/privacy_settings_page.dart'; import '../../../settings/presentation/pages/feedback_page.dart'; @@ -48,9 +57,23 @@ class _ProfilePageState extends State String _selectedTheme = 'Système'; bool _biometricEnabled = false; bool _twoFactorEnabled = false; - // Confidentialité et langue gérées dans les pages dédiées (PrivacySettingsPage, LanguageSettingsPage) + // Préférences notifications (persistées via SharedPreferences) + bool _notifPush = true; + bool _notifEmail = false; + bool _notifSon = true; + // Infos app (chargées via package_info_plus) + String _appVersion = '...'; + String _platformInfo = '...'; + // Tailles stockage (calculées au chargement) + String _cacheSize = '...'; + String _imagesSize = '...'; + String _offlineSize = '...'; final List _themes = ['Système', 'Clair', 'Sombre']; + static const _keyNotifPush = 'notif_push'; + static const _keyNotifEmail = 'notif_email'; + static const _keyNotifSon = 'notif_son'; + @override void initState() { super.initState(); @@ -59,6 +82,9 @@ class _ProfilePageState extends State _syncLanguageFromProvider(); _loadProfileFromAuth(); }); + _loadPreferences(); + _loadDeviceInfo(); + _loadStorageInfo(); } @override @@ -97,13 +123,22 @@ class _ProfilePageState extends State if (state is ProfileUpdating) { setState(() => _isLoading = true); } + if (state is PasswordChanged) { + _showSuccessSnackBar('Mot de passe modifié avec succès'); + } + if (state is AccountDeleted) { + _showSuccessSnackBar('Compte supprimé'); + context.read().add(AuthLogoutRequested()); + } }, child: Scaffold( - backgroundColor: AppColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: UFAppBar( title: 'MON PROFIL', - backgroundColor: AppColors.surface, - foregroundColor: AppColors.textPrimaryLight, + backgroundColor: Theme.of(context).cardColor, + foregroundColor: Theme.of(context).brightness == Brightness.dark + ? AppColors.textPrimaryDark + : AppColors.textPrimaryLight, actions: [ IconButton( icon: Icon(_isEditing ? Icons.save_outlined : Icons.edit_outlined, size: 20), @@ -115,7 +150,7 @@ class _ProfilePageState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), children: [ _buildHeader(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildTabBar(), SizedBox( height: 600, // Ajuster selon contenu ou utiliser NestedScrollView @@ -135,46 +170,149 @@ class _ProfilePageState extends State ); } - /// Header harmonisé avec photo de profil + /// Header avec données réelles du backend Widget _buildHeader() { - return CoreCard( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( + return BlocBuilder( + builder: (context, state) { + final membre = (state is ProfileLoaded) + ? state.membre + : (state is ProfileUpdated ? state.membre + : (state is ProfileUpdating ? state.membre : null)); + + final isLoading = state is ProfileLoading || state is ProfileInitial; + final hasError = state is ProfileError || state is ProfileNotFound; + + // Badge : rôle de l'utilisateur (SUPER_ADMIN, ADMIN, etc.) + statut + String badgeText; + Color badgeColor; + if (isLoading) { + badgeText = 'CHARGEMENT...'; + badgeColor = AppColors.textSecondaryLight; + } else if (hasError) { + badgeText = 'ERREUR CHARGEMENT'; + badgeColor = AppColors.error; + } else if (membre != null) { + // Priorité au rôle s'il est disponible + if (membre.role != null && membre.role!.isNotEmpty) { + badgeText = membre.role!.replaceAll('_', ' '); + badgeColor = AppColors.primaryGreen; + } else { + // Fallback sur le statut + switch (membre.statut) { + case StatutMembre.actif: + badgeText = membre.cotisationAJour ? 'MEMBRE ACTIF' : 'COTISATION EN RETARD'; + badgeColor = membre.cotisationAJour ? AppColors.success : AppColors.warning; + break; + case StatutMembre.inactif: + badgeText = 'INACTIF'; + badgeColor = AppColors.textSecondaryLight; + break; + case StatutMembre.suspendu: + badgeText = 'SUSPENDU'; + badgeColor = AppColors.error; + break; + case StatutMembre.enAttente: + badgeText = 'EN ATTENTE'; + badgeColor = AppColors.warning; + break; + } + } + } else { + badgeText = 'CHARGEMENT...'; + badgeColor = AppColors.textSecondaryLight; + } + + // Ancienneté réelle + String depuisValue = '—'; + if (membre?.dateAdhesion != null) { + final days = membre!.ancienneteJours ?? 0; + if (days < 30) { + depuisValue = '$days J'; + } else if (days < 365) { + depuisValue = '${(days / 30).floor()} MOIS'; + } else { + depuisValue = '${(days / 365).floor()} ANS'; + } + } + + // Événements réels + final eventsValue = membre != null + ? '${membre.nombreEvenementsParticipes}' + : '—'; + + // Organisation réelle + final orgValue = membre?.organisationNom != null ? '1' : '—'; + + return CoreCard( + padding: const EdgeInsets.all(16), + child: Column( children: [ - const MiniAvatar(size: 64, fallbackText: '👤'), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_firstNameController.text} ${_lastNameController.text}'.toUpperCase(), - style: AppTypography.actionText.copyWith(fontSize: 14, fontWeight: FontWeight.bold), + Row( + children: [ + GestureDetector( + onTap: _pickProfileImage, + child: MiniAvatar( + size: 64, + fallbackText: membre != null + ? membre.initiales + : (_firstNameController.text.isNotEmpty + ? _firstNameController.text[0].toUpperCase() + : '?'), + imageUrl: membre?.photo, ), - Text( - _emailController.text.toLowerCase(), - style: AppTypography.subtitleSmall.copyWith(fontSize: 11), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_firstNameController.text} ${_lastNameController.text}'.trim().toUpperCase(), + style: AppTypography.actionText.copyWith(fontSize: 14, fontWeight: FontWeight.bold), + ), + Text( + _emailController.text.toLowerCase(), + style: AppTypography.subtitleSmall.copyWith(fontSize: 11), + ), + const SizedBox(height: 8), + if (isLoading) + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + InfoBadge(text: badgeText, backgroundColor: badgeColor), + if (hasError) + GestureDetector( + onTap: _loadProfileFromAuth, + child: Text( + 'Réessayer', + style: AppTypography.subtitleSmall.copyWith( + color: AppColors.primaryGreen, + fontSize: 10, + decoration: TextDecoration.underline, + ), + ), + ), + ], ), - const SizedBox(height: 8), - const InfoBadge(text: 'MEMBRE ACTIF', backgroundColor: AppColors.success), - ], - ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem('DEPUIS', depuisValue), + _buildStatItem('EVENTS', eventsValue), + _buildStatItem('ORG', orgValue), + ], ), ], ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildStatItem('DEPUIS', '2 ANS'), - _buildStatItem('EVENTS', '24'), - _buildStatItem('ORGS', '3'), - ], - ), - ], - ), + ); + }, ); } @@ -225,16 +363,17 @@ class _ProfilePageState extends State /// Barre d'onglets Widget _buildTabBar() { + final isDark = Theme.of(context).brightness == Brightness.dark; return Container( decoration: BoxDecoration( - color: AppColors.surface, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.lightBorder, width: 0.5), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), ), child: TabBar( controller: _tabController, labelColor: AppColors.primaryGreen, - unselectedLabelColor: AppColors.textSecondaryLight, + unselectedLabelColor: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, indicatorColor: AppColors.primaryGreen, indicatorSize: TabBarIndicatorSize.label, labelStyle: AppTypography.actionText.copyWith(fontSize: 10, fontWeight: FontWeight.bold), @@ -254,7 +393,7 @@ class _ProfilePageState extends State padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), // Section informations de base _buildInfoSection( @@ -413,32 +552,44 @@ class _ProfilePageState extends State int maxLines = 1, String? hintText, }) { + final isDark = Theme.of(context).brightness == Brightness.dark; return TextFormField( controller: controller, enabled: enabled, keyboardType: keyboardType, maxLines: maxLines, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 12), + style: AppTypography.bodyTextSmall.copyWith( + fontSize: 12, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, + ), decoration: InputDecoration( labelText: label.toUpperCase(), - labelStyle: AppTypography.subtitleSmall.copyWith(fontSize: 9, fontWeight: FontWeight.bold), + labelStyle: AppTypography.subtitleSmall.copyWith( + fontSize: 9, + fontWeight: FontWeight.bold, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + ), hintText: hintText, - prefixIcon: Icon(icon, color: enabled ? AppColors.primaryGreen : Colors.grey, size: 16), + prefixIcon: Icon(icon, color: enabled ? AppColors.primaryGreen : AppColors.textSecondaryLight, size: 16), filled: true, - fillColor: AppColors.surface, + fillColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(4), - borderSide: const BorderSide(color: AppColors.lightBorder), + borderSide: BorderSide(color: isDark ? AppColors.darkBorder : AppColors.lightBorder), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), - borderSide: const BorderSide(color: AppColors.lightBorder), + borderSide: BorderSide(color: isDark ? AppColors.darkBorder : AppColors.lightBorder), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: const BorderSide(color: AppColors.primaryGreen, width: 1), ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), + ), ), validator: (value) { if (label == 'Email' && value != null && value.isNotEmpty) { @@ -453,10 +604,11 @@ class _ProfilePageState extends State /// Boutons d'action Widget _buildActionButtons() { + final isDark = Theme.of(context).brightness == Brightness.dark; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? AppColors.darkSurface : Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( @@ -487,7 +639,7 @@ class _ProfilePageState extends State child: ElevatedButton.icon( onPressed: _isLoading ? null : _saveProfile, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), @@ -510,7 +662,7 @@ class _ProfilePageState extends State child: ElevatedButton.icon( onPressed: _startEditing, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), @@ -539,44 +691,59 @@ class _ProfilePageState extends State 'Personnaliser l\'affichage', Icons.language, [ - InkWell( - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const LanguageSettingsPage()), - ); - // Resynchroniser la langue après retour - if (mounted) { - final lp = context.read(); - setState(() => _selectedLanguage = lp.currentLanguageName); - } - }, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - Icon(Icons.language, color: Colors.grey[600], size: 22), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Langue', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey[800])), - Text('Actuellement : $_selectedLanguage', style: TextStyle(fontSize: 12, color: Colors.grey[600])), - ], - ), + Builder( + builder: (ctx) { + final isDark = Theme.of(ctx).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + return InkWell( + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const LanguageSettingsPage()), + ); + if (mounted) { + final lp = context.read(); + setState(() => _selectedLanguage = lp.currentLanguageName); + } + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon(Icons.language, color: textSecondary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Langue', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary)), + Text('Actuellement : $_selectedLanguage', style: AppTypography.subtitleSmall.copyWith(color: textSecondary)), + ], + ), + ), + Icon(Icons.chevron_right, color: textSecondary, size: 20), + ], ), - Icon(Icons.chevron_right, color: Colors.grey[400]), - ], - ), - ), + ), + ); + }, ), _buildDropdownPreference( 'Thème', 'Apparence de l\'application', _selectedTheme, _themes, - (value) => setState(() => _selectedTheme = value!), + (value) { + if (value == null) return; + setState(() => _selectedTheme = value); + final mode = switch (value) { + 'Clair' => ThemeMode.light, + 'Sombre' => ThemeMode.dark, + _ => ThemeMode.system, + }; + context.read().setMode(mode); + }, ), ], ), @@ -592,20 +759,29 @@ class _ProfilePageState extends State _buildSwitchPreference( 'Notifications push', 'Recevoir des notifications sur cet appareil', - true, - (value) => _showSuccessSnackBar('Préférence mise à jour'), + _notifPush, + (value) async { + setState(() => _notifPush = value); + await _savePreference(_keyNotifPush, value); + }, ), _buildSwitchPreference( 'Notifications email', 'Recevoir des emails de notification', - false, - (value) => _showSuccessSnackBar('Préférence mise à jour'), + _notifEmail, + (value) async { + setState(() => _notifEmail = value); + await _savePreference(_keyNotifEmail, value); + }, ), _buildSwitchPreference( 'Sons et vibrations', 'Alertes sonores et vibrations', - true, - (value) => _showSuccessSnackBar('Préférence mise à jour'), + _notifSon, + (value) async { + setState(() => _notifSon = value); + await _savePreference(_keyNotifSon, value); + }, ), ], ), @@ -618,32 +794,39 @@ class _ProfilePageState extends State 'Contrôler vos données', Icons.privacy_tip, [ - InkWell( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const PrivacySettingsPage()), + Builder( + builder: (ctx) { + final isDark = Theme.of(ctx).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + return InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const PrivacySettingsPage()), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon(Icons.privacy_tip, color: textSecondary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gérer la confidentialité', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary)), + Text('Visibilité, partage de données, suppression de compte', style: AppTypography.subtitleSmall.copyWith(color: textSecondary)), + ], + ), + ), + Icon(Icons.chevron_right, color: textSecondary, size: 20), + ], + ), + ), ); }, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - Icon(Icons.privacy_tip, color: Colors.grey[600], size: 22), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Gérer la confidentialité', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey[800])), - Text('Visibilité, partage de données, suppression de compte', style: TextStyle(fontSize: 12, color: Colors.grey[600])), - ], - ), - ), - Icon(Icons.chevron_right, color: Colors.grey[400]), - ], - ), - ), ), ], ), @@ -715,22 +898,16 @@ class _ProfilePageState extends State // Sessions actives _buildSecuritySection( - 'Sessions actives', - 'Gérer vos connexions', + 'Session active', + 'Cet appareil', Icons.devices, [ _buildSessionItem( 'Cet appareil', - 'Android • Maintenant', - Icons.smartphone, + '${kIsWeb ? "Web" : Platform.isAndroid ? "Android" : Platform.isIOS ? "iOS" : "Bureau"} • Maintenant', + kIsWeb ? Icons.web : Platform.isAndroid ? Icons.smartphone : Platform.isIOS ? Icons.phone_iphone : Icons.computer, true, ), - _buildSessionItem( - 'Navigateur Web', - 'Chrome • Il y a 2 heures', - Icons.web, - false, - ), ], ), @@ -746,14 +923,14 @@ class _ProfilePageState extends State 'Télécharger mes données', 'Exporter toutes vos données personnelles', Icons.download, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _exportUserData(), ), _buildActionItem( 'Déconnecter tous les appareils', 'Fermer toutes les sessions actives', Icons.logout, - const Color(0xFFE17055), + AppColors.warning, () => _logoutAllDevices(), ), _buildActionItem( @@ -787,11 +964,21 @@ class _ProfilePageState extends State 'Données et stockage', 'Gérer l\'utilisation des données', Icons.storage, - [ - _buildStorageItem('Cache de l\'application', '45 MB', () => _clearCache()), - _buildStorageItem('Images téléchargées', '128 MB', () => _clearImages()), - _buildStorageItem('Données hors ligne', '12 MB', () => _clearOfflineData()), - ], + kIsWeb + ? [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Gestion du stockage non disponible sur navigateur web.', + style: AppTypography.subtitleSmall.copyWith(fontSize: 11), + ), + ), + ] + : [ + _buildStorageItem('Cache de l\'application', _cacheSize, () => _clearCache()), + _buildStorageItem('Images téléchargées', _imagesSize, () => _clearImages()), + _buildStorageItem('Données hors ligne', _offlineSize, () => _clearOfflineData()), + ], ), const SizedBox(height: 16), @@ -825,10 +1012,8 @@ class _ProfilePageState extends State 'Détails techniques', Icons.info, [ - _buildInfoItem('Version de l\'app', '2.1.0 (Build 42)'), - _buildInfoItem('Version Flutter', '3.16.0'), - _buildInfoItem('Plateforme', 'Android 13'), - _buildInfoItem('ID de l\'appareil', 'R58R34HT85V'), + _buildInfoItem('Version de l\'app', _appVersion), + _buildInfoItem('Plateforme', _platformInfo), ], ), @@ -840,42 +1025,39 @@ class _ProfilePageState extends State 'Aidez-nous à améliorer l\'application', Icons.feedback, [ - InkWell( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const FeedbackPage()), + Builder( + builder: (ctx) { + final isDark = Theme.of(ctx).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; + return InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const FeedbackPage()), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon(Icons.edit_note, color: textSecondary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Envoyer des commentaires', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary)), + Text('Suggestions, bugs ou idées d\'amélioration', style: AppTypography.subtitleSmall.copyWith(color: textSecondary)), + ], + ), + ), + Icon(Icons.chevron_right, color: textSecondary, size: 20), + ], + ), + ), ); }, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - Icon(Icons.edit_note, color: Colors.grey[600], size: 22), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Envoyer des commentaires', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), - ), - Text( - 'Suggestions, bugs ou idées d\'amélioration', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ), - Icon(Icons.chevron_right, color: Colors.grey[400]), - ], - ), - ), ), ], ), @@ -950,6 +1132,7 @@ class _ProfilePageState extends State List options, Function(String?) onChanged, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -961,9 +1144,9 @@ class _ProfilePageState extends State Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( - color: AppColors.surface, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, borderRadius: BorderRadius.circular(4), - border: Border.all(color: AppColors.lightBorder, width: 0.5), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), ), child: DropdownButtonHideUnderline( child: DropdownButton( @@ -992,6 +1175,7 @@ class _ProfilePageState extends State bool value, Function(bool) onChanged, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Row( children: [ Expanded( @@ -1004,7 +1188,10 @@ class _ProfilePageState extends State ), Text( subtitle, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight), + style: AppTypography.bodyTextSmall.copyWith( + fontSize: 10, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + ), ), ], ), @@ -1028,15 +1215,17 @@ class _ProfilePageState extends State IconData icon, VoidCallback onTap, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(4), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: AppColors.surface, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, borderRadius: BorderRadius.circular(4), - border: Border.all(color: AppColors.lightBorder, width: 0.5), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), ), child: Row( children: [ @@ -1052,12 +1241,12 @@ class _ProfilePageState extends State ), Text( subtitle, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight), + style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: textSecondary), ), ], ), ), - const Icon(Icons.arrow_forward_ios, color: AppColors.textSecondaryLight, size: 12), + Icon(Icons.arrow_forward_ios, color: textSecondary, size: 12), ], ), ), @@ -1071,13 +1260,19 @@ class _ProfilePageState extends State IconData icon, bool isCurrentDevice, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isCurrentDevice ? AppColors.primaryGreen.withOpacity(0.05) : AppColors.surface, + color: isCurrentDevice + ? AppColors.primaryGreen.withOpacity(0.05) + : (isDark ? AppColors.darkSurface : AppColors.lightSurface), borderRadius: BorderRadius.circular(4), border: Border.all( - color: isCurrentDevice ? AppColors.primaryGreen.withOpacity(0.3) : AppColors.lightBorder, + color: isCurrentDevice + ? AppColors.primaryGreen.withOpacity(0.3) + : (isDark ? AppColors.darkBorder : AppColors.lightBorder), width: 0.5, ), ), @@ -1085,7 +1280,7 @@ class _ProfilePageState extends State children: [ Icon( icon, - color: isCurrentDevice ? AppColors.primaryGreen : AppColors.textSecondaryLight, + color: isCurrentDevice ? AppColors.primaryGreen : textSecondary, size: 16, ), const SizedBox(width: 12), @@ -1107,19 +1302,11 @@ class _ProfilePageState extends State ), Text( subtitle, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight), + style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: textSecondary), ), ], ), ), - if (!isCurrentDevice) - IconButton( - onPressed: () => _terminateSession(title), - icon: const Icon(Icons.close, color: AppColors.error, size: 16), - tooltip: 'Terminer la session', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), ], ), ); @@ -1133,6 +1320,7 @@ class _ProfilePageState extends State Color color, VoidCallback onTap, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(4), @@ -1157,7 +1345,10 @@ class _ProfilePageState extends State ), Text( subtitle, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight), + style: AppTypography.bodyTextSmall.copyWith( + fontSize: 10, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + ), ), ], ), @@ -1171,15 +1362,16 @@ class _ProfilePageState extends State /// Élément de stockage Widget _buildStorageItem(String title, String size, VoidCallback onTap) { + final isDark = Theme.of(context).brightness == Brightness.dark; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(4), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: AppColors.surface, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, borderRadius: BorderRadius.circular(4), - border: Border.all(color: AppColors.lightBorder, width: 0.5), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), ), child: Row( children: [ @@ -1193,7 +1385,7 @@ class _ProfilePageState extends State ), Text( size, - style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, fontWeight: FontWeight.bold), + style: AppTypography.subtitleSmall.copyWith(fontSize: 10), ), const SizedBox(width: 8), const Icon(Icons.clear, color: AppColors.error, size: 14), @@ -1205,12 +1397,13 @@ class _ProfilePageState extends State /// Élément d'information Widget _buildInfoItem(String title, String value) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.surface, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, borderRadius: BorderRadius.circular(4), - border: Border.all(color: AppColors.lightBorder, width: 0.5), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, width: 0.5), ), child: Row( children: [ @@ -1231,11 +1424,19 @@ class _ProfilePageState extends State // ==================== MÉTHODES D'ACTION ==================== - /// Synchronise la langue affichée avec le LocaleProvider + /// Synchronise la langue et le thème affichés depuis leurs providers void _syncLanguageFromProvider() { if (!mounted) return; final lp = context.read(); - setState(() => _selectedLanguage = lp.currentLanguageName); + final tp = context.read(); + setState(() { + _selectedLanguage = lp.currentLanguageName; + _selectedTheme = switch (tp.mode) { + ThemeMode.light => 'Clair', + ThemeMode.dark => 'Sombre', + _ => 'Système', + }; + }); } // Confidentialité gérée dans PrivacySettingsPage @@ -1337,62 +1538,77 @@ class _ProfilePageState extends State ); } - /// Terminer une session - void _terminateSession(String deviceName) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Terminer la session'), - content: Text('Êtes-vous sûr de vouloir terminer la session sur "$deviceName" ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - _showSuccessSnackBar('Session terminée sur $deviceName'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - child: const Text('Terminer'), - ), - ], - ), - ); + /// Charger les préférences notifications depuis SharedPreferences + Future _loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _notifPush = prefs.getBool(_keyNotifPush) ?? true; + _notifEmail = prefs.getBool(_keyNotifEmail) ?? false; + _notifSon = prefs.getBool(_keyNotifSon) ?? true; + }); + } } - /// Dialogue de changement de mot de passe + /// Persiste une préférence booléenne + Future _savePreference(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + } + + /// Charge les informations réelles de l'application + Future _loadDeviceInfo() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _appVersion = '${info.version} (Build ${info.buildNumber})'; + final os = kIsWeb + ? 'Web' + : Platform.isAndroid + ? 'Android' + : Platform.isIOS + ? 'iOS' + : Platform.operatingSystem; + _platformInfo = '$os · ${info.appName}'; + }); + } + } + + /// Dialogue de changement de mot de passe (branché sur le backend Keycloak) void _showChangePasswordDialog() { + final oldPassCtrl = TextEditingController(); + final newPassCtrl = TextEditingController(); + final confirmPassCtrl = TextEditingController(); + showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Changer le mot de passe'), - content: const Column( + content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( + controller: oldPassCtrl, obscureText: true, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: 'Mot de passe actuel', border: OutlineInputBorder(), ), ), - SizedBox(height: 16), + const SizedBox(height: 16), TextField( + controller: newPassCtrl, obscureText: true, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: 'Nouveau mot de passe', border: OutlineInputBorder(), ), ), - SizedBox(height: 16), + const SizedBox(height: 16), TextField( + controller: confirmPassCtrl, obscureText: true, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: 'Confirmer le nouveau mot de passe', border: OutlineInputBorder(), ), @@ -1401,16 +1617,47 @@ class _ProfilePageState extends State ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + Navigator.of(ctx).pop(); + oldPassCtrl.dispose(); + newPassCtrl.dispose(); + confirmPassCtrl.dispose(); + }, child: const Text('Annuler'), ), ElevatedButton( onPressed: () { - Navigator.of(context).pop(); - _showSuccessSnackBar('Mot de passe modifié avec succès'); + final oldPass = oldPassCtrl.text.trim(); + final newPass = newPassCtrl.text.trim(); + final confirmPass = confirmPassCtrl.text.trim(); + if (oldPass.isEmpty || newPass.isEmpty || confirmPass.isEmpty) { + _showErrorSnackBar('Veuillez remplir tous les champs'); + return; + } + if (newPass != confirmPass) { + _showErrorSnackBar('Les mots de passe ne correspondent pas'); + return; + } + if (newPass.length < 8) { + _showErrorSnackBar('Le mot de passe doit contenir au moins 8 caractères'); + return; + } + if (_membreId == null) { + _showErrorSnackBar('Profil non chargé'); + return; + } + Navigator.of(ctx).pop(); + context.read().add(ChangePassword( + membreId: _membreId!, + oldPassword: oldPass, + newPassword: newPass, + )); + oldPassCtrl.dispose(); + newPassCtrl.dispose(); + confirmPassCtrl.dispose(); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), + backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white, ), child: const Text('Modifier'), @@ -1420,107 +1667,74 @@ class _ProfilePageState extends State ); } - /// Configuration de l'authentification à deux facteurs + /// 2FA : non disponible (Keycloak géré côté admin) void _showTwoFactorSetupDialog() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Authentification à deux facteurs'), content: const Text( - 'L\'authentification à deux facteurs ajoute une couche de sécurité supplémentaire à votre compte. ' - 'Vous recevrez un code par SMS ou via une application d\'authentification.', + 'La configuration 2FA est gérée par votre administrateur Keycloak. ' + 'Contactez votre organisation pour activer cette fonctionnalité.', ), actions: [ TextButton( onPressed: () { - Navigator.of(context).pop(); + Navigator.of(ctx).pop(); setState(() => _twoFactorEnabled = false); }, - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - _showSuccessSnackBar('Authentification à deux facteurs configurée'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6C5CE7), - foregroundColor: Colors.white, - ), - child: const Text('Configurer'), + child: const Text('Fermer'), ), ], ), ); } - /// Exporter les données utilisateur + /// Export RGPD : non disponible (pas d'endpoint backend) void _exportUserData() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Télécharger mes données'), content: const Text( - 'Nous allons préparer un fichier contenant toutes vos données personnelles. ' - 'Vous recevrez un email avec le lien de téléchargement dans les 24 heures.', + 'Cette fonctionnalité sera disponible prochainement. ' + 'Pour demander l\'export de vos données, contactez l\'administrateur de votre organisation.', ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - _showSuccessSnackBar('Demande d\'export envoyée. Vous recevrez un email.'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0984E3), - foregroundColor: Colors.white, - ), - child: const Text('Demander l\'export'), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fermer'), ), ], ), ); } - /// Déconnecter tous les appareils + /// Déconnexion tous appareils : non disponible (pas d'endpoint backend) void _logoutAllDevices() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Déconnecter tous les appareils'), content: const Text( - 'Cette action fermera toutes vos sessions actives sur tous les appareils. ' - 'Vous devrez vous reconnecter partout.', + 'Cette fonctionnalité sera disponible prochainement. ' + 'Vous pouvez révoquer vos sessions depuis l\'interface Keycloak de votre organisation.', ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - _showSuccessSnackBar('Toutes les sessions ont été fermées'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE17055), - foregroundColor: Colors.white, - ), - child: const Text('Déconnecter tout'), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fermer'), ), ], ), ); } - /// Dialogue de suppression de compte + /// Suppression de compte (branché sur le backend) void _showDeleteAccountDialog() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Supprimer mon compte'), content: const Text( 'ATTENTION : Cette action est irréversible !\n\n' @@ -1532,12 +1746,12 @@ class _ProfilePageState extends State ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(ctx).pop(), child: const Text('Annuler'), ), ElevatedButton( onPressed: () { - Navigator.of(context).pop(); + Navigator.of(ctx).pop(); _showFinalDeleteConfirmation(); }, style: ElevatedButton.styleFrom( @@ -1551,19 +1765,21 @@ class _ProfilePageState extends State ); } - /// Confirmation finale de suppression + /// Confirmation finale — déclenche la suppression réelle sur le backend void _showFinalDeleteConfirmation() { + final confirmCtrl = TextEditingController(); showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( title: const Text('Confirmation finale'), - content: const Column( + content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Tapez "SUPPRIMER" pour confirmer :'), - SizedBox(height: 16), + const Text('Tapez "SUPPRIMER" pour confirmer :'), + const SizedBox(height: 16), TextField( - decoration: InputDecoration( + controller: confirmCtrl, + decoration: const InputDecoration( border: OutlineInputBorder(), hintText: 'SUPPRIMER', ), @@ -1572,13 +1788,25 @@ class _ProfilePageState extends State ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + Navigator.of(ctx).pop(); + confirmCtrl.dispose(); + }, child: const Text('Annuler'), ), ElevatedButton( onPressed: () { - Navigator.of(context).pop(); - _showErrorSnackBar('Fonctionnalité désactivée pour la démo'); + if (confirmCtrl.text.trim() != 'SUPPRIMER') { + _showErrorSnackBar('Tapez exactement "SUPPRIMER" pour confirmer'); + return; + } + if (_membreId == null) { + _showErrorSnackBar('Profil non chargé'); + return; + } + Navigator.of(ctx).pop(); + context.read().add(DeleteAccount(_membreId!)); + confirmCtrl.dispose(); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, @@ -1591,19 +1819,103 @@ class _ProfilePageState extends State ); } - /// Vider le cache - void _clearCache() { - _showSuccessSnackBar('Cache vidé (45 MB libérés)'); + /// Vide le cache temporaire de l'application + /// Calcule la taille d'un répertoire (récursif) en format lisible + Future _dirSize(Directory dir) async { + if (!dir.existsSync()) return '0 B'; + int bytes = 0; + try { + await for (final entity in dir.list(recursive: true)) { + if (entity is File) bytes += await entity.length(); + } + } catch (_) {} + if (bytes == 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } - /// Vider les images - void _clearImages() { - _showSuccessSnackBar('Images supprimées (128 MB libérés)'); + /// Charge les tailles des répertoires de stockage + Future _loadStorageInfo() async { + if (kIsWeb) return; + try { + final tmp = await getTemporaryDirectory(); + final docs = await getApplicationDocumentsDirectory(); + final support = await getApplicationSupportDirectory(); + final cacheSize = await _dirSize(tmp); + final imagesSize = await _dirSize(Directory('${docs.path}/images')); + final offlineSize = await _dirSize(Directory('${support.path}/offline')); + if (mounted) { + setState(() { + _cacheSize = cacheSize; + _imagesSize = imagesSize; + _offlineSize = offlineSize; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _cacheSize = 'N/A'; + _imagesSize = 'N/A'; + _offlineSize = 'N/A'; + }); + } + } } - /// Vider les données hors ligne - void _clearOfflineData() { - _showSuccessSnackBar('Données hors ligne supprimées (12 MB libérés)'); + Future _clearCache() async { + if (kIsWeb) return; + try { + final dir = await getTemporaryDirectory(); + if (dir.existsSync()) { + dir.listSync().forEach((e) { + try { e.deleteSync(recursive: true); } catch (_) {} + }); + } + _showSuccessSnackBar('Cache vidé avec succès'); + } catch (_) { + _showErrorSnackBar('Erreur lors du vidage du cache'); + } finally { + _loadStorageInfo(); + } + } + + /// Vide le répertoire des images téléchargées + Future _clearImages() async { + if (kIsWeb) return; + try { + final dir = await getApplicationDocumentsDirectory(); + final imgDir = Directory('${dir.path}/images'); + if (imgDir.existsSync()) { + imgDir.listSync().forEach((e) { + try { e.deleteSync(recursive: true); } catch (_) {} + }); + } + _showSuccessSnackBar('Images supprimées'); + } catch (_) { + _showErrorSnackBar('Erreur lors de la suppression des images'); + } finally { + _loadStorageInfo(); + } + } + + /// Vide les données hors ligne + Future _clearOfflineData() async { + if (kIsWeb) return; + try { + final dir = await getApplicationSupportDirectory(); + final offlineDir = Directory('${dir.path}/offline'); + if (offlineDir.existsSync()) { + offlineDir.listSync().forEach((e) { + try { e.deleteSync(recursive: true); } catch (_) {} + }); + } + _showSuccessSnackBar('Données hors ligne supprimées'); + } catch (_) { + _showErrorSnackBar('Erreur lors de la suppression des données hors ligne'); + } finally { + _loadStorageInfo(); + } } /// Afficher un message de succès diff --git a/lib/features/reports/data/repositories/reports_repository.dart b/lib/features/reports/data/repositories/reports_repository.dart index 092d016..4a734ef 100644 --- a/lib/features/reports/data/repositories/reports_repository.dart +++ b/lib/features/reports/data/repositories/reports_repository.dart @@ -37,6 +37,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return []; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getMetriques échoué', error: e, stackTrace: st); if (e.response?.statusCode == 404 || e.response?.statusCode == 400) return []; rethrow; @@ -52,6 +53,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return {}; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getPerformanceGlobale échoué', error: e, stackTrace: st); return {}; } @@ -72,6 +74,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return []; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getEvolutions échoué', error: e, stackTrace: st); return []; } @@ -80,12 +83,13 @@ class ReportsRepositoryImpl implements IReportsRepository { @override Future> getStatistiquesMembres() async { try { - final response = await _apiClient.get('$_membresBase/statistiques'); + final response = await _apiClient.get('$_membresBase/stats'); if (response.statusCode == 200 && response.data is Map) { return response.data as Map; } return {}; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getStatistiquesMembres échoué', error: e, stackTrace: st); return {}; } @@ -103,6 +107,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return {}; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getStatistiquesCotisations échoué', error: e, stackTrace: st); return {}; } @@ -117,6 +122,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return {}; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getStatistiquesEvenements échoué', error: e, stackTrace: st); return {}; } @@ -134,6 +140,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return []; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getAvailableReports échoué', error: e, stackTrace: st); return []; } @@ -152,6 +159,7 @@ class ReportsRepositoryImpl implements IReportsRepository { throw Exception('Generate report failed: ${response.statusCode}'); } } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: generateReport échoué', error: e, stackTrace: st); rethrow; } @@ -171,6 +179,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } throw Exception('Export PDF failed: ${response.statusCode}'); } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: exportReportPdf échoué', error: e, stackTrace: st); rethrow; } @@ -190,6 +199,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } throw Exception('Export $format failed: ${response.statusCode}'); } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: exportReportExcel échoué', error: e, stackTrace: st); rethrow; } @@ -206,6 +216,7 @@ class ReportsRepositoryImpl implements IReportsRepository { throw Exception('Schedule report failed: ${response.statusCode}'); } } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: scheduleReport échoué', error: e, stackTrace: st); rethrow; } @@ -223,6 +234,7 @@ class ReportsRepositoryImpl implements IReportsRepository { } return []; } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; AppLogger.error('ReportsRepository: getScheduledReports échoué', error: e, stackTrace: st); return []; } diff --git a/lib/features/reports/presentation/bloc/reports_bloc.dart b/lib/features/reports/presentation/bloc/reports_bloc.dart index d6d0937..9c018bc 100644 --- a/lib/features/reports/presentation/bloc/reports_bloc.dart +++ b/lib/features/reports/presentation/bloc/reports_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des rapports (Clean Architecture) library reports_bloc; +import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; @@ -52,6 +53,7 @@ class ReportsBloc extends Bloc { statsEvenements: results[3], )); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ReportsError('Erreur lors du chargement des rapports : $e')); } } @@ -65,6 +67,7 @@ class ReportsBloc extends Bloc { await _scheduleReport(cronExpression: event.cronExpression); emit(const ReportScheduled()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ReportsError('Impossible de programmer le rapport : $e')); } } @@ -78,6 +81,7 @@ class ReportsBloc extends Bloc { await _generateReport(event.type, format: event.format); emit(ReportGenerated(event.type)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(ReportsError('Impossible de générer le rapport : $e')); } } diff --git a/lib/features/reports/presentation/pages/reports_page.dart b/lib/features/reports/presentation/pages/reports_page.dart index 94abe0a..cd3c20b 100644 --- a/lib/features/reports/presentation/pages/reports_page.dart +++ b/lib/features/reports/presentation/pages/reports_page.dart @@ -63,18 +63,18 @@ class _ReportsPageState extends State } if (state is ReportScheduled) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message), backgroundColor: const Color(0xFF00B894), behavior: SnackBarBehavior.floating), + SnackBar(content: Text(state.message), backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating), ); } if (state is ReportGenerated) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message), backgroundColor: const Color(0xFF00B894), behavior: SnackBarBehavior.floating), + SnackBar(content: Text(state.message), backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating), ); } }, builder: (context, state) { return Scaffold( - backgroundColor: AppColors.darkBackground, + backgroundColor: AppColors.lightBackground, body: Column( children: [ _buildHeader(), @@ -107,10 +107,10 @@ class _ReportsPageState extends State return Container( width: double.infinity, padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 20, - bottom: 30, - left: 20, - right: 20, + top: MediaQuery.of(context).padding.top + 12, + bottom: 16, + left: 12, + right: 12, ), decoration: const BoxDecoration( gradient: LinearGradient( @@ -162,7 +162,7 @@ class _ReportsPageState extends State ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -197,10 +197,10 @@ class _ReportsPageState extends State Widget _buildHeaderStat(String label, String value, IconData icon) { return Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.white.withOpacity(0.2)), ), child: Column( @@ -228,7 +228,7 @@ class _ReportsPageState extends State return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: AppColors.darkBackground, + color: AppColors.lightBackground, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.lightBorder.withOpacity(0.1)), ), @@ -252,14 +252,14 @@ class _ReportsPageState extends State Widget _buildOverviewTab() { return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), children: [ _buildKPICards(), - const SizedBox(height: 24), + const SizedBox(height: 12), _buildActivityChart(), - const SizedBox(height: 24), + const SizedBox(height: 12), _buildQuickReports(), - const SizedBox(height: 32), + const SizedBox(height: 16), ], ); } @@ -275,15 +275,15 @@ class _ReportsPageState extends State Row( children: [ Expanded(child: _buildKPICard('Total Membres', totalMembres, Icons.people_outline, AppColors.info)), - const SizedBox(width: 16), + const SizedBox(width: 8), Expanded(child: _buildKPICard('Membres Actifs', membresActifs, Icons.how_to_reg_outlined, AppColors.success)), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ Expanded(child: _buildKPICard('Cotisations', totalCotisations, Icons.payments_outlined, AppColors.brandGreen)), - const SizedBox(width: 16), + const SizedBox(width: 8), Expanded(child: _buildKPICard('Événements', totalEvenements, Icons.event_available_outlined, AppColors.warning)), ], ), @@ -293,18 +293,18 @@ class _ReportsPageState extends State Widget _buildKPICard(String title, String value, IconData icon, Color color) { return CoreCard( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), child: Column( children: [ Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.1), shape: BoxShape.circle, ), - child: Icon(icon, color: color, size: 24), + child: Icon(icon, color: color, size: 20), ), - const SizedBox(height: 12), + const SizedBox(height: 6), Text( value, style: AppTypography.headerSmall.copyWith(color: color, fontWeight: FontWeight.bold), @@ -326,21 +326,21 @@ class _ReportsPageState extends State Widget _buildActivityChart() { return CoreCard( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.analytics_outlined, color: AppColors.primaryGreen, size: 20), - const SizedBox(width: 12), + const SizedBox(width: 8), Text( 'Évolution de l\'Activité'.toUpperCase(), style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 10), Container( height: 180, decoration: BoxDecoration( @@ -368,21 +368,21 @@ class _ReportsPageState extends State Widget _buildQuickReports() { return CoreCard( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.flash_on_outlined, color: AppColors.warning, size: 20), - const SizedBox(width: 12), + const SizedBox(width: 8), Text( 'Rapports Favoris'.toUpperCase(), style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 10), _buildQuickReportItem('Bilan Annuel', 'Synthèse financière et activité', Icons.summarize_outlined, () => _generateReport('monthly')), _buildQuickReportItem('Engagement Membres', 'Analyse de participation globale', Icons.query_stats_outlined, () => _generateReport('top_members')), _buildQuickReportItem('Impact Événements', 'Analyse SEO et participation', Icons.insights_outlined, () => _generateReport('events_analysis')), @@ -393,12 +393,12 @@ class _ReportsPageState extends State Widget _buildQuickReportItem(String title, String subtitle, IconData icon, VoidCallback onTap) { return Container( - margin: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(bottom: 6), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.lightBorder.withOpacity(0.05), borderRadius: BorderRadius.circular(12), @@ -439,11 +439,11 @@ class _ReportsPageState extends State padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), _buildMembersStats(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildMembersReports(), - const SizedBox(height: 80), + const SizedBox(height: 40), ], ), ); @@ -455,21 +455,21 @@ class _ReportsPageState extends State final actifs = _statsMembres['actifs7j']?.toString() ?? '--'; return CoreCard( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.people_alt_outlined, color: AppColors.info, size: 20), - const SizedBox(width: 12), + const SizedBox(width: 8), Text( 'Indicateurs Membres'.toUpperCase(), style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1), ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 10), Row( children: [ Expanded(child: _buildStatItem('Total', total)), @@ -752,7 +752,7 @@ class _ReportsPageState extends State Navigator.of(context).pop(); context.read().add(GenerateReportRequested('export', format: _selectedFormat)); }, - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white), child: const Text('Exporter'), ), ], @@ -770,7 +770,7 @@ class _ReportsPageState extends State void _showSuccessSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: const Color(0xFF00B894), behavior: SnackBarBehavior.floating), + SnackBar(content: Text(message), backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating), ); } } diff --git a/lib/features/settings/data/repositories/system_config_repository.dart b/lib/features/settings/data/repositories/system_config_repository.dart index a539d2d..b811d5b 100644 --- a/lib/features/settings/data/repositories/system_config_repository.dart +++ b/lib/features/settings/data/repositories/system_config_repository.dart @@ -91,6 +91,7 @@ class SystemConfigRepositoryImpl implements ISystemConfigRepository { } throw Exception('Erreur ${response.statusCode}'); } on DioException catch (e, st) { + if (e.type == DioExceptionType.cancel) rethrow; // Si l'endpoint n'existe pas (404), fallback : récupérer config par défaut via GET if (e.response?.statusCode == 404) { AppLogger.warning( diff --git a/lib/features/settings/presentation/bloc/system_settings_bloc.dart b/lib/features/settings/presentation/bloc/system_settings_bloc.dart index 25f329a..1668105 100644 --- a/lib/features/settings/presentation/bloc/system_settings_bloc.dart +++ b/lib/features/settings/presentation/bloc/system_settings_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour la gestion des paramètres système (Clean Architecture) library system_settings_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../../domain/usecases/get_settings.dart'; @@ -48,6 +49,7 @@ class SystemSettingsBloc extends Bloc final config = await _getSettings(); // ✅ Use case emit(SystemConfigLoaded(config)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de chargement: ${e.toString()}')); } } @@ -62,6 +64,7 @@ class SystemSettingsBloc extends Bloc emit(SystemConfigLoaded(config)); emit(const SystemSettingsSuccess('Configuration mise à jour')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de mise à jour: ${e.toString()}')); } } @@ -75,6 +78,7 @@ class SystemSettingsBloc extends Bloc final stats = await _getCacheStats(); // ✅ Use case emit(CacheStatsLoaded(stats)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de chargement: ${e.toString()}')); } } @@ -101,6 +105,7 @@ class SystemSettingsBloc extends Bloc await _clearCache(); // ✅ Use case emit(const SystemSettingsSuccess('Cache vidé avec succès')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur: ${e.toString()}')); } } @@ -120,6 +125,7 @@ class SystemSettingsBloc extends Bloc emit(SystemSettingsError(message)); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de test: ${e.toString()}')); } } @@ -139,6 +145,7 @@ class SystemSettingsBloc extends Bloc emit(SystemSettingsError(message)); } } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de test: ${e.toString()}')); } } @@ -154,6 +161,7 @@ class SystemSettingsBloc extends Bloc emit(SystemConfigLoaded(config)); emit(const SystemSettingsSuccess('Configuration réinitialisée')); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(SystemSettingsError('Erreur de réinitialisation: ${e.toString()}')); } } diff --git a/lib/features/settings/presentation/pages/feedback_page.dart b/lib/features/settings/presentation/pages/feedback_page.dart index 876f6dd..af50acd 100644 --- a/lib/features/settings/presentation/pages/feedback_page.dart +++ b/lib/features/settings/presentation/pages/feedback_page.dart @@ -21,10 +21,10 @@ class _FeedbackPageState extends State { bool _isSending = false; static const _categories = [ - _FeedbackCategory('suggestion', 'Suggestion', Icons.lightbulb, Color(0xFF6C5CE7)), - _FeedbackCategory('bug', 'Bug / Problème', Icons.bug_report, Color(0xFFE17055)), - _FeedbackCategory('amelioration', 'Amélioration', Icons.trending_up, Color(0xFF00B894)), - _FeedbackCategory('autre', 'Autre', Icons.help_outline, Color(0xFF0984E3)), + _FeedbackCategory('suggestion', 'Suggestion', Icons.lightbulb, AppColors.primaryGreen), + _FeedbackCategory('bug', 'Bug / Problème', Icons.bug_report, AppColors.error), + _FeedbackCategory('amelioration', 'Amélioration', Icons.trending_up, AppColors.success), + _FeedbackCategory('autre', 'Autre', Icons.help_outline, AppColors.brandGreen), ]; @override @@ -69,7 +69,7 @@ class _FeedbackPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: isError ? Colors.red : const Color(0xFF00B894), + backgroundColor: isError ? AppColors.error : AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -78,7 +78,7 @@ class _FeedbackPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ _buildHeader(), @@ -87,11 +87,11 @@ class _FeedbackPageState extends State { padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), _buildCategorySection(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildMessageSection(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildSubmitButton(), const SizedBox(height: 80), ], @@ -105,18 +105,18 @@ class _FeedbackPageState extends State { Widget _buildHeader() { return Container( - margin: const EdgeInsets.all(SpacingTokens.lg), - padding: const EdgeInsets.all(SpacingTokens.xxl), + margin: const EdgeInsets.symmetric(horizontal: SpacingTokens.sm, vertical: SpacingTokens.xs), + padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( gradient: const LinearGradient( - colors: ColorTokens.primaryGradient, + colors: [AppColors.brandGreen, AppColors.primaryGreen], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(SpacingTokens.xl), boxShadow: [ BoxShadow( - color: ColorTokens.primary.withOpacity(0.3), + color: AppColors.primaryGreen.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 8), ), @@ -131,14 +131,14 @@ class _FeedbackPageState extends State { icon: const Icon(Icons.arrow_back, color: Colors.white), ), Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.feedback, color: Colors.white, size: 24), + child: const Icon(Icons.feedback, color: Colors.white, size: 20), ), - const SizedBox(width: 16), + const SizedBox(width: 12), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -168,11 +168,14 @@ class _FeedbackPageState extends State { } Widget _buildCategorySection() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), @@ -186,19 +189,15 @@ class _FeedbackPageState extends State { children: [ Row( children: [ - Icon(Icons.category, color: Colors.grey[600], size: 20), + Icon(Icons.category, color: textSecondary, size: 20), const SizedBox(width: 8), Text( 'Type de retour', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), + style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Wrap( spacing: 10, runSpacing: 10, @@ -210,31 +209,34 @@ class _FeedbackPageState extends State { } Widget _buildCategoryChip(_FeedbackCategory cat) { + final isDark = Theme.of(context).brightness == Brightness.dark; final isSelected = _selectedCategory == cat.id; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return InkWell( onTap: () => setState(() => _selectedCategory = cat.id), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: isSelected ? cat.color.withOpacity(0.12) : Colors.grey[50], + color: isSelected + ? cat.color.withOpacity(0.12) + : (isDark ? AppColors.darkBackground : Colors.grey[50]), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected ? cat.color.withOpacity(0.5) : Colors.grey[200]!, + color: isSelected ? cat.color.withOpacity(0.5) : (isDark ? AppColors.darkBorder : Colors.grey[200]!), width: isSelected ? 1.5 : 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(cat.icon, size: 18, color: isSelected ? cat.color : Colors.grey[500]), + Icon(cat.icon, size: 18, color: isSelected ? cat.color : textSecondary), const SizedBox(width: 8), Text( cat.label, - style: TextStyle( - fontSize: 13, + style: AppTypography.bodyTextSmall.copyWith( fontWeight: FontWeight.w600, - color: isSelected ? cat.color : Colors.grey[700], + color: isSelected ? cat.color : textSecondary, ), ), ], @@ -244,11 +246,14 @@ class _FeedbackPageState extends State { } Widget _buildMessageSection() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), @@ -262,38 +267,36 @@ class _FeedbackPageState extends State { children: [ Row( children: [ - Icon(Icons.edit_note, color: Colors.grey[600], size: 20), + Icon(Icons.edit_note, color: textSecondary, size: 20), const SizedBox(width: 8), Text( 'Votre message', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), + style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), TextField( controller: _messageController, maxLines: 6, + style: AppTypography.bodyTextSmall.copyWith(color: textPrimary), decoration: InputDecoration( hintText: 'Décrivez votre suggestion, problème ou idée...', + hintStyle: AppTypography.subtitleSmall.copyWith(color: textSecondary), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey[300]!), + borderSide: BorderSide(color: isDark ? AppColors.darkBorder : Colors.grey[300]!), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey[300]!), + borderSide: BorderSide(color: isDark ? AppColors.darkBorder : Colors.grey[300]!), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: ColorTokens.primary, width: 1.5), + borderSide: const BorderSide(color: AppColors.primaryGreen, width: 1.5), ), filled: true, - fillColor: Colors.grey[50], + fillColor: isDark ? AppColors.darkBackground : Colors.grey[50], alignLabelWithHint: true, ), ), @@ -323,10 +326,10 @@ class _FeedbackPageState extends State { ), ), style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: AppColors.primaryGreen, + padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), elevation: 2, ), diff --git a/lib/features/settings/presentation/pages/language_settings_page.dart b/lib/features/settings/presentation/pages/language_settings_page.dart index d8c97f8..a23a9bf 100644 --- a/lib/features/settings/presentation/pages/language_settings_page.dart +++ b/lib/features/settings/presentation/pages/language_settings_page.dart @@ -43,7 +43,7 @@ class _LanguageSettingsPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Langue changée en $languageName'), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -53,7 +53,7 @@ class _LanguageSettingsPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ _buildHeader(), @@ -62,9 +62,9 @@ class _LanguageSettingsPageState extends State { padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), _buildLanguageList(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildInfoSection(), const SizedBox(height: 80), ], @@ -82,14 +82,14 @@ class _LanguageSettingsPageState extends State { padding: const EdgeInsets.all(SpacingTokens.xxl), decoration: BoxDecoration( gradient: const LinearGradient( - colors: ColorTokens.primaryGradient, + colors: [AppColors.brandGreen, AppColors.primaryGreen], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(SpacingTokens.xl), boxShadow: [ BoxShadow( - color: ColorTokens.primary.withOpacity(0.3), + color: AppColors.primaryGreen.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 8), ), @@ -104,12 +104,12 @@ class _LanguageSettingsPageState extends State { icon: const Icon(Icons.arrow_back, color: Colors.white), ), Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.language, color: Colors.white, size: 24), + child: const Icon(Icons.language, color: Colors.white, size: 20), ), const SizedBox(width: 16), Expanded( @@ -141,37 +141,29 @@ class _LanguageSettingsPageState extends State { } Widget _buildLanguageList() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.translate, color: Colors.grey[600], size: 20), + Icon(Icons.translate, color: textSecondary, size: 20), const SizedBox(width: 8), Text( 'Langues disponibles', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), + style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), ..._supportedLanguages.map((lang) => _buildLanguageTile(lang)), ], ), @@ -179,22 +171,25 @@ class _LanguageSettingsPageState extends State { } Widget _buildLanguageTile(_LanguageOption lang) { + final isDark = Theme.of(context).brightness == Brightness.dark; final isSelected = _selectedLanguage == lang.name; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(bottom: 6), child: InkWell( onTap: () => _changeLanguage(lang.name, lang.code), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: isSelected - ? ColorTokens.primary.withOpacity(0.08) - : Colors.grey[50], - borderRadius: BorderRadius.circular(12), + ? AppColors.primaryGreen.withOpacity(0.08) + : (isDark ? AppColors.darkBackground : Colors.grey[50]), + borderRadius: BorderRadius.circular(8), border: isSelected - ? Border.all(color: ColorTokens.primary.withOpacity(0.4), width: 1.5) - : Border.all(color: Colors.grey[200]!), + ? Border.all(color: AppColors.primaryGreen.withOpacity(0.4), width: 1.5) + : Border.all(color: isDark ? AppColors.darkBorder : Colors.grey[200]!), ), child: Row( children: [ @@ -206,21 +201,20 @@ class _LanguageSettingsPageState extends State { children: [ Text( lang.name, - style: TextStyle( - fontSize: 15, + style: AppTypography.bodyTextSmall.copyWith( fontWeight: FontWeight.w600, - color: isSelected ? ColorTokens.primary : const Color(0xFF1F2937), + color: isSelected ? AppColors.primaryGreen : textPrimary, ), ), Text( lang.description, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + style: AppTypography.subtitleSmall.copyWith(color: textSecondary), ), ], ), ), if (isSelected) - const Icon(Icons.check_circle, color: ColorTokens.primary, size: 22), + const Icon(Icons.check_circle, color: AppColors.primaryGreen, size: 22), ], ), ), @@ -229,41 +223,33 @@ class _LanguageSettingsPageState extends State { } Widget _buildInfoSection() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.info_outline, color: Colors.grey[600], size: 20), + Icon(Icons.info_outline, color: textSecondary, size: 20), const SizedBox(width: 8), Text( 'Information', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), + style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), Text( 'Le changement de langue s\'applique immédiatement à toute l\'interface. ' 'Les contenus générés par le serveur restent dans leur langue d\'origine.', - style: TextStyle(fontSize: 13, color: Colors.grey[600], height: 1.5), + style: AppTypography.subtitleSmall.copyWith(color: textSecondary, height: 1.5), ), ], ), diff --git a/lib/features/settings/presentation/pages/privacy_settings_page.dart b/lib/features/settings/presentation/pages/privacy_settings_page.dart index f898af0..202247c 100644 --- a/lib/features/settings/presentation/pages/privacy_settings_page.dart +++ b/lib/features/settings/presentation/pages/privacy_settings_page.dart @@ -52,7 +52,7 @@ class _PrivacySettingsPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -61,7 +61,7 @@ class _PrivacySettingsPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ _buildHeader(), @@ -70,11 +70,11 @@ class _PrivacySettingsPageState extends State { padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), _buildVisibilitySection(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildDataSection(), - const SizedBox(height: 16), + const SizedBox(height: 8), _buildDangerSection(), const SizedBox(height: 80), ], @@ -92,14 +92,14 @@ class _PrivacySettingsPageState extends State { padding: const EdgeInsets.all(SpacingTokens.xxl), decoration: BoxDecoration( gradient: const LinearGradient( - colors: ColorTokens.primaryGradient, + colors: [AppColors.brandGreen, AppColors.primaryGreen], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(SpacingTokens.xl), boxShadow: [ BoxShadow( - color: ColorTokens.primary.withOpacity(0.3), + color: AppColors.primaryGreen.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 8), ), @@ -114,12 +114,12 @@ class _PrivacySettingsPageState extends State { icon: const Icon(Icons.arrow_back, color: Colors.white), ), Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.privacy_tip, color: Colors.white, size: 24), + child: const Icon(Icons.privacy_tip, color: Colors.white, size: 20), ), const SizedBox(width: 16), const Expanded( @@ -218,12 +218,12 @@ class _PrivacySettingsPageState extends State { [ InkWell( onTap: _showDeleteAccountDialog, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: Colors.red.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.withOpacity(0.2)), ), child: Row( @@ -242,14 +242,21 @@ class _PrivacySettingsPageState extends State { color: Colors.red, ), ), - Text( - 'Supprimer définitivement toutes vos données', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + Builder( + builder: (ctx) { + final isDark = Theme.of(ctx).brightness == Brightness.dark; + return Text( + 'Supprimer définitivement toutes vos données', + style: AppTypography.subtitleSmall.copyWith( + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, + ), + ); + }, ), ], ), ), - Icon(Icons.arrow_forward_ios, color: Colors.grey[400], size: 16), + const Icon(Icons.arrow_forward_ios, color: Colors.red, size: 16), ], ), ), @@ -295,25 +302,21 @@ class _PrivacySettingsPageState extends State { } Widget _buildSection(String title, String subtitle, IconData icon, List children) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(icon, color: Colors.grey[600], size: 20), + Icon(icon, color: textSecondary, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -321,24 +324,20 @@ class _PrivacySettingsPageState extends State { children: [ Text( title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[800], - ), + style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), Text( subtitle, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + style: AppTypography.subtitleSmall.copyWith(color: textSecondary), ), ], ), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), ...children.map((child) => Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only(bottom: 6), child: child, )), ], @@ -352,15 +351,18 @@ class _PrivacySettingsPageState extends State { bool value, ValueChanged onChanged, ) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight; return Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(12), + color: isDark ? AppColors.darkBackground : Colors.grey[50], + borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.toggle_on, color: ColorTokens.primary, size: 20), + const Icon(Icons.toggle_on, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 12), Expanded( child: Column( @@ -368,15 +370,11 @@ class _PrivacySettingsPageState extends State { children: [ Text( title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), + style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600, color: textPrimary), ), Text( subtitle, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + style: AppTypography.subtitleSmall.copyWith(color: textSecondary), ), ], ), @@ -384,7 +382,7 @@ class _PrivacySettingsPageState extends State { Switch( value: value, onChanged: onChanged, - activeTrackColor: ColorTokens.primary, + activeTrackColor: AppColors.primaryGreen, thumbColor: WidgetStateProperty.resolveWith((states) => states.contains(WidgetState.selected) ? Colors.white : null), ), diff --git a/lib/features/settings/presentation/pages/system_settings_page.dart b/lib/features/settings/presentation/pages/system_settings_page.dart index 188eabd..45741d7 100644 --- a/lib/features/settings/presentation/pages/system_settings_page.dart +++ b/lib/features/settings/presentation/pages/system_settings_page.dart @@ -65,7 +65,7 @@ class _SystemSettingsPageState extends State // Accès réservé aux super administrateurs (configuration système globale) if (authState is! AuthAuthenticated || authState.effectiveRole != UserRole.superAdmin) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: AppColors.lightBackground, appBar: AppBar( title: const Text('Paramètres Système'), leading: IconButton( @@ -80,14 +80,14 @@ class _SystemSettingsPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.lock_outline, size: 64, color: ColorTokens.onSurfaceVariant.withOpacity(0.5)), + Icon(Icons.lock_outline, size: 64, color: AppColors.textSecondaryLight.withOpacity(0.5)), const SizedBox(height: 16), Text( 'Accès réservé', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: ColorTokens.onSurface, + color: AppColors.textPrimaryLight, ), textAlign: TextAlign.center, ), @@ -96,7 +96,7 @@ class _SystemSettingsPageState extends State 'Les paramètres système sont réservés aux administrateurs plateforme.', style: TextStyle( fontSize: 14, - color: ColorTokens.onSurfaceVariant, + color: AppColors.textSecondaryLight, ), textAlign: TextAlign.center, ), @@ -121,7 +121,7 @@ class _SystemSettingsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), - backgroundColor: ColorTokens.success, + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -129,7 +129,7 @@ class _SystemSettingsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.error), - backgroundColor: ColorTokens.error, + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); @@ -137,7 +137,7 @@ class _SystemSettingsPageState extends State }, builder: (context, state) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: AppColors.lightBackground, body: Column( children: [ // Header harmonisé @@ -172,18 +172,18 @@ class _SystemSettingsPageState extends State /// Header harmonisé avec indicateurs système Widget _buildHeader() { return Container( - margin: const EdgeInsets.all(SpacingTokens.lg), - padding: const EdgeInsets.all(SpacingTokens.xxl), + margin: const EdgeInsets.symmetric(horizontal: SpacingTokens.sm, vertical: SpacingTokens.xs), + padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( gradient: const LinearGradient( - colors: ColorTokens.primaryGradient, + colors: [AppColors.brandGreen, AppColors.primaryGreen], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(SpacingTokens.xl), boxShadow: [ BoxShadow( - color: ColorTokens.primary.withOpacity(0.3), + color: AppColors.primaryGreen.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 8), ), @@ -194,18 +194,18 @@ class _SystemSettingsPageState extends State Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.settings, color: Colors.white, - size: 24, + size: 20, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -263,8 +263,8 @@ class _SystemSettingsPageState extends State ), ], ), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // Indicateurs système Row( children: [ @@ -354,9 +354,9 @@ class _SystemSettingsPageState extends State ), child: TabBar( controller: _tabController, - labelColor: ColorTokens.primary, - unselectedLabelColor: ColorTokens.onSurfaceVariant, - indicatorColor: ColorTokens.primary, + labelColor: AppColors.primaryGreen, + unselectedLabelColor: AppColors.textSecondaryLight, + indicatorColor: AppColors.primaryGreen, indicatorWeight: 3, indicatorSize: TabBarIndicatorSize.tab, labelStyle: const TextStyle( @@ -399,7 +399,7 @@ class _SystemSettingsPageState extends State padding: const EdgeInsets.all(12), child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 8), // Configuration de base _buildSettingsSection( @@ -457,14 +457,14 @@ class _SystemSettingsPageState extends State ? 'Supprimer tous les fichiers temporaires (${_metrics!.totalCacheSizeFormatted ?? "0 B"})' : 'Supprimer tous les fichiers temporaires', Icons.delete_sweep, - const Color(0xFFE17055), + AppColors.warning, () => _clearSystemCache(), ), _buildActionSetting( 'Optimiser la base de données', 'Réorganiser et compacter la base de données', Icons.tune, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _optimizeDatabase(), ), ], @@ -485,7 +485,7 @@ class _SystemSettingsPageState extends State 'Tester la connectivité', 'Vérifier la connexion aux services', Icons.network_ping, - const Color(0xFF00B894), + AppColors.success, () => _testConnectivity(), ), ], @@ -533,7 +533,7 @@ class _SystemSettingsPageState extends State 'Régénérer les clés API', 'Créer de nouvelles clés d\'authentification', Icons.vpn_key, - const Color(0xFFE17055), + AppColors.warning, () => _regenerateApiKeys(), ), ], @@ -566,7 +566,7 @@ class _SystemSettingsPageState extends State 'Réinitialiser les sessions', 'Nettoyer les sessions expirées', Icons.refresh, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _resetSessions(), ), ], @@ -584,21 +584,21 @@ class _SystemSettingsPageState extends State 'Générer rapport d\'audit', 'Créer un rapport complet des activités', Icons.assessment, - ColorTokens.primary, + AppColors.primaryGreen, () => _generateAuditReport(), ), _buildActionSetting( 'Export RGPD', 'Exporter toutes les données utilisateurs', Icons.download, - const Color(0xFF00B894), + AppColors.success, () => _exportGDPRData(), ), _buildActionSetting( 'Purge des données', 'Supprimer les données expirées (RGPD)', Icons.auto_delete, - const Color(0xFFE17055), + AppColors.warning, () => _purgeExpiredData(), ), ], @@ -701,14 +701,14 @@ class _SystemSettingsPageState extends State 'Analyser les performances', 'Scanner les goulots d\'étranglement', Icons.analytics, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _analyzePerformance(), ), _buildActionSetting( 'Nettoyer les logs anciens', 'Supprimer les logs de plus de 30 jours', Icons.cleaning_services, - const Color(0xFFE17055), + AppColors.warning, () => _cleanOldLogs(), ), _buildActionSetting( @@ -761,14 +761,14 @@ class _SystemSettingsPageState extends State 'Créer une sauvegarde maintenant', 'Sauvegarder immédiatement toutes les données', Icons.save, - const Color(0xFF00B894), + AppColors.success, () => _createBackup(), ), _buildActionSetting( 'Restaurer depuis une sauvegarde', 'Récupérer des données depuis un fichier', Icons.restore, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _restoreFromBackup(), ), ], @@ -794,7 +794,7 @@ class _SystemSettingsPageState extends State 'Planifier une maintenance', 'Programmer une fenêtre de maintenance', Icons.schedule, - ColorTokens.primary, + AppColors.primaryGreen, () => _scheduleMaintenance(), ), _buildActionSetting( @@ -827,14 +827,14 @@ class _SystemSettingsPageState extends State 'Vérifier les mises à jour', 'Rechercher les nouvelles versions', Icons.refresh, - const Color(0xFF0984E3), + AppColors.primaryGreen, () => _checkUpdates(), ), _buildActionSetting( 'Historique des mises à jour', 'Voir les versions précédentes', Icons.history, - ColorTokens.primary, + AppColors.primaryGreen, () => _showUpdateHistory(), ), ], @@ -864,13 +864,13 @@ class _SystemSettingsPageState extends State 'CPU élevé', 'Alerte si CPU > 80% pendant 5 min', true, - const Color(0xFFE17055), + AppColors.warning, ), _buildAlertItem( 'Mémoire faible', 'Alerte si RAM < 20% disponible', true, - const Color(0xFFE17055), + AppColors.warning, ), _buildAlertItem( 'Disque plein', @@ -882,7 +882,7 @@ class _SystemSettingsPageState extends State 'Connexions échouées', 'Alerte si > 100 échecs/min', false, - const Color(0xFF0984E3), + AppColors.primaryGreen, ), ], ), @@ -919,14 +919,14 @@ class _SystemSettingsPageState extends State 'Voir tous les logs', 'Ouvrir la console de logs complète', Icons.terminal, - ColorTokens.primary, + AppColors.primaryGreen, () => _viewAllLogs(), ), _buildActionSetting( 'Exporter les logs', 'Télécharger les logs pour analyse', Icons.download, - const Color(0xFF00B894), + AppColors.success, () => _exportLogs(), ), ], @@ -960,7 +960,7 @@ class _SystemSettingsPageState extends State 'Rapport détaillé', 'Générer un rapport complet d\'utilisation', Icons.assessment, - ColorTokens.primary, + AppColors.primaryGreen, () => _generateUsageReport(), ), ], @@ -1053,9 +1053,9 @@ class _SystemSettingsPageState extends State child: Row( children: [ if (isWarning) - const Icon(Icons.warning, color: ColorTokens.warning, size: 20) + const Icon(Icons.warning, color: AppColors.warning, size: 20) else - const Icon(Icons.toggle_on, color: ColorTokens.primary, size: 20), + const Icon(Icons.toggle_on, color: AppColors.primaryGreen, size: 20), const SizedBox(width: 12), Expanded( child: Column( @@ -1066,7 +1066,7 @@ class _SystemSettingsPageState extends State style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: isWarning ? Colors.orange[800] : const Color(0xFF1F2937), + color: isWarning ? AppColors.warning : AppColors.textPrimaryLight, ), ), Text( @@ -1082,7 +1082,7 @@ class _SystemSettingsPageState extends State Switch( value: value, onChanged: onChanged, - activeColor: isWarning ? ColorTokens.warning : ColorTokens.primary, + activeColor: isWarning ? AppColors.warning : AppColors.primaryGreen, ), ], ), @@ -1108,7 +1108,7 @@ class _SystemSettingsPageState extends State children: [ Row( children: [ - const Icon(Icons.arrow_drop_down, color: ColorTokens.primary, size: 20), + const Icon(Icons.arrow_drop_down, color: AppColors.primaryGreen, size: 20), const SizedBox(width: SpacingTokens.lg), Expanded( child: Column( @@ -1119,7 +1119,7 @@ class _SystemSettingsPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), Text( @@ -1230,7 +1230,7 @@ class _SystemSettingsPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), ), @@ -1266,7 +1266,7 @@ class _SystemSettingsPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), ), @@ -1360,7 +1360,7 @@ class _SystemSettingsPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), ), @@ -1394,7 +1394,7 @@ class _SystemSettingsPageState extends State ), child: Row( children: [ - const Icon(Icons.bar_chart, color: ColorTokens.primary, size: 20), + const Icon(Icons.bar_chart, color: AppColors.primaryGreen, size: 20), const SizedBox(width: SpacingTokens.lg), Expanded( child: Text( @@ -1402,7 +1402,7 @@ class _SystemSettingsPageState extends State style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), + color: AppColors.textPrimaryLight, ), ), ), @@ -1455,8 +1455,8 @@ class _SystemSettingsPageState extends State _showSuccessSnackBar('État du système actualisé'); }, style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, ), child: const Text('Actualiser'), ), @@ -1507,8 +1507,8 @@ class _SystemSettingsPageState extends State _showSuccessSnackBar('Configuration exportée avec succès'); }, style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.primary, - foregroundColor: ColorTokens.onPrimary, + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, ), child: const Text('Exporter'), ), @@ -1625,7 +1625,7 @@ class _SystemSettingsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFF00B894), + backgroundColor: AppColors.success, behavior: SnackBarBehavior.floating, ), ); @@ -1636,7 +1636,7 @@ class _SystemSettingsPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: const Color(0xFFE74C3C), + backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, ), ); diff --git a/lib/features/solidarity/bloc/solidarity_bloc.dart b/lib/features/solidarity/bloc/solidarity_bloc.dart index 61c4a60..f7b5038 100644 --- a/lib/features/solidarity/bloc/solidarity_bloc.dart +++ b/lib/features/solidarity/bloc/solidarity_bloc.dart @@ -1,6 +1,7 @@ /// BLoC pour les demandes d'aide (solidarité) library solidarity_bloc; +import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; @@ -29,6 +30,7 @@ class SolidarityBloc extends Bloc { final list = await _repository.getMesDemandes(page: event.page, size: event.size); emit(state.copyWith(status: SolidarityStatus.loaded, demandes: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } @@ -39,6 +41,7 @@ class SolidarityBloc extends Bloc { final demande = await _repository.getById(event.id); emit(state.copyWith(status: SolidarityStatus.loaded, demandeDetail: demande)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } @@ -54,6 +57,7 @@ class SolidarityBloc extends Bloc { ); emit(state.copyWith(status: SolidarityStatus.loaded, demandes: list)); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } @@ -64,6 +68,7 @@ class SolidarityBloc extends Bloc { await _repository.create(event.demande); add(const LoadDemandesAide()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } @@ -75,6 +80,7 @@ class SolidarityBloc extends Bloc { emit(state.copyWith(status: SolidarityStatus.loaded, demandeDetail: updated)); add(const LoadDemandesAide()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } @@ -86,6 +92,7 @@ class SolidarityBloc extends Bloc { emit(state.copyWith(status: SolidarityStatus.loaded, demandeDetail: updated)); add(const LoadDemandesAide()); } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) return; emit(state.copyWith(status: SolidarityStatus.error, message: e.toString(), error: e)); } } diff --git a/lib/features/solidarity/presentation/pages/demande_aide_detail_page.dart b/lib/features/solidarity/presentation/pages/demande_aide_detail_page.dart index 1364701..476ebd8 100644 --- a/lib/features/solidarity/presentation/pages/demande_aide_detail_page.dart +++ b/lib/features/solidarity/presentation/pages/demande_aide_detail_page.dart @@ -66,7 +66,7 @@ class _DemandeAideDetailPageState extends State { ); } return SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/main.dart b/lib/main.dart index 512a3cb..91682ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; import 'app/app.dart'; import 'core/config/environment.dart'; import 'core/l10n/locale_provider.dart'; +import 'core/theme/theme_provider.dart'; import 'core/di/injection.dart'; void main() async { @@ -24,7 +25,9 @@ void main() async { final localeProvider = LocaleProvider(); await localeProvider.initialize(); - runApp(UnionFlowApp(localeProvider: localeProvider)); + final themeProvider = await ThemeProvider.load(); + + runApp(UnionFlowApp(localeProvider: localeProvider, themeProvider: themeProvider)); } /// Configure les paramètres globaux de l'application diff --git a/lib/shared/design_system/components/buttons/uf_primary_button.dart b/lib/shared/design_system/components/buttons/uf_primary_button.dart index 4f40117..f35c6dc 100644 --- a/lib/shared/design_system/components/buttons/uf_primary_button.dart +++ b/lib/shared/design_system/components/buttons/uf_primary_button.dart @@ -1,6 +1,6 @@ /// UnionFlow Primary Button - Bouton principal -/// -/// Bouton primaire avec la couleur Bleu Roi (#4169E1) +/// +/// Bouton primaire Vert Forêt (#2E7D32) /// Utilisé pour les actions principales (connexion, enregistrer, valider, etc.) library uf_primary_button; @@ -59,22 +59,22 @@ class UFPrimaryButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: isFullWidth ? double.infinity : null, - height: height ?? SpacingTokens.buttonHeightLarge, + height: height ?? SpacingTokens.buttonHeightMedium, child: ElevatedButton( onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor ?? AppColors.primaryGreen, - foregroundColor: textColor ?? Colors.white, + backgroundColor: backgroundColor ?? AppColors.primaryGreen, + foregroundColor: textColor ?? Colors.white, disabledBackgroundColor: (backgroundColor ?? AppColors.primaryGreen).withOpacity(0.5), disabledForegroundColor: (textColor ?? Colors.white).withOpacity(0.7), elevation: SpacingTokens.elevationSm, shadowColor: AppColors.darkBorder.withOpacity(0.1), padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.buttonPaddingHorizontal, - vertical: SpacingTokens.buttonPaddingVertical, + vertical: 10, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), ), child: isLoading diff --git a/lib/shared/design_system/components/buttons/uf_secondary_button.dart b/lib/shared/design_system/components/buttons/uf_secondary_button.dart index 9e4d42d..fa2e709 100644 --- a/lib/shared/design_system/components/buttons/uf_secondary_button.dart +++ b/lib/shared/design_system/components/buttons/uf_secondary_button.dart @@ -1,6 +1,6 @@ /// UnionFlow Secondary Button - Bouton secondaire -/// -/// Bouton secondaire avec la couleur Indigo (#6366F1) +/// +/// Bouton secondaire Vert Menthe (#4CAF50) /// Utilisé pour les actions secondaires (annuler, retour, etc.) library uf_secondary_button; @@ -30,22 +30,22 @@ class UFSecondaryButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: isFullWidth ? double.infinity : null, - height: height ?? SpacingTokens.buttonHeightLarge, + height: height ?? SpacingTokens.buttonHeightMedium, child: ElevatedButton( onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom( - backgroundColor: AppColors.brandGreen, - foregroundColor: Colors.white, + backgroundColor: AppColors.brandGreen, + foregroundColor: Colors.white, disabledBackgroundColor: AppColors.brandGreen.withOpacity(0.5), disabledForegroundColor: Colors.white.withOpacity(0.7), elevation: SpacingTokens.elevationSm, shadowColor: AppColors.darkBorder.withOpacity(0.1), padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.buttonPaddingHorizontal, - vertical: SpacingTokens.buttonPaddingVertical, + vertical: 10, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), ), child: isLoading diff --git a/lib/shared/design_system/components/cards/uf_card.dart b/lib/shared/design_system/components/cards/uf_card.dart index 1e3a18f..12a31b9 100644 --- a/lib/shared/design_system/components/cards/uf_card.dart +++ b/lib/shared/design_system/components/cards/uf_card.dart @@ -83,9 +83,9 @@ class UFCard extends StatelessWidget { @override Widget build(BuildContext context) { - final effectivePadding = padding ?? const EdgeInsets.all(SpacingTokens.cardPadding); + final effectivePadding = padding ?? const EdgeInsets.all(SpacingTokens.lg); final effectiveMargin = margin ?? EdgeInsets.zero; - final effectiveBorderRadius = borderRadius ?? SpacingTokens.radiusLg; + final effectiveBorderRadius = borderRadius ?? SpacingTokens.radiusMd; Widget content = Container( padding: effectivePadding, @@ -114,15 +114,6 @@ class UFCard extends StatelessWidget { return BoxDecoration( color: color ?? AppColors.lightSurface, borderRadius: BorderRadius.circular(radius), - boxShadow: elevation != null - ? [ - BoxShadow( - color: AppColors.darkBorder.withOpacity(0.1), - blurRadius: elevation!, - offset: const Offset(0, 2), - ), - ] - : ShadowTokens.sm, ); case UFCardStyle.outlined: diff --git a/lib/shared/design_system/components/cards/uf_info_card.dart b/lib/shared/design_system/components/cards/uf_info_card.dart index 0cd3d3d..7cf69f5 100644 --- a/lib/shared/design_system/components/cards/uf_info_card.dart +++ b/lib/shared/design_system/components/cards/uf_info_card.dart @@ -49,15 +49,15 @@ class UFInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveIconColor = iconColor ?? AppColors.primaryGreen; - final effectivePadding = padding ?? const EdgeInsets.all(SpacingTokens.xl); + final effectivePadding = padding ?? const EdgeInsets.all(SpacingTokens.lg); return Container( padding: effectivePadding, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), - boxShadow: ShadowTokens.sm, + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -65,20 +65,20 @@ class UFInfoCard extends StatelessWidget { // Header avec titre et trailing Row( children: [ - Icon(icon, color: effectiveIconColor, size: 20), + Icon(icon, color: effectiveIconColor, size: 16), const SizedBox(width: SpacingTokens.md), Expanded( child: Text( title, style: AppTypography.headerSmall.copyWith( - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, ), ), ), if (trailing != null) trailing!, ], ), - const SizedBox(height: SpacingTokens.xl), + const SizedBox(height: SpacingTokens.lg), // Contenu child, ], diff --git a/lib/shared/design_system/components/cards/uf_metric_card.dart b/lib/shared/design_system/components/cards/uf_metric_card.dart index 848d96b..90ced06 100644 --- a/lib/shared/design_system/components/cards/uf_metric_card.dart +++ b/lib/shared/design_system/components/cards/uf_metric_card.dart @@ -45,7 +45,7 @@ class UFMetricCard extends StatelessWidget { padding: const EdgeInsets.all(SpacingTokens.md), decoration: BoxDecoration( color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), child: Column( children: [ diff --git a/lib/shared/design_system/components/cards/uf_stat_card.dart b/lib/shared/design_system/components/cards/uf_stat_card.dart index 06511a6..a473016 100644 --- a/lib/shared/design_system/components/cards/uf_stat_card.dart +++ b/lib/shared/design_system/components/cards/uf_stat_card.dart @@ -55,21 +55,22 @@ class UFStatCard extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveIconColor = iconColor ?? AppColors.primaryGreen; - final effectiveIconBgColor = iconBackgroundColor ?? + final effectiveIconBgColor = iconBackgroundColor ?? effectiveIconColor.withOpacity(0.1); return Card( elevation: SpacingTokens.elevationSm, shadowColor: AppColors.darkBorder.withOpacity(0.1), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), child: Padding( - padding: const EdgeInsets.all(SpacingTokens.cardPadding), + padding: const EdgeInsets.all(SpacingTokens.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -79,55 +80,55 @@ class UFStatCard extends StatelessWidget { children: [ // Icône avec background coloré Container( - padding: const EdgeInsets.all(SpacingTokens.md), + padding: const EdgeInsets.all(SpacingTokens.sm), decoration: BoxDecoration( color: effectiveIconBgColor, - borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), ), child: Icon( icon, color: effectiveIconColor, - size: 24, + size: 18, ), ), const Spacer(), // Flèche si cliquable if (onTap != null) - const Icon( + Icon( Icons.arrow_forward_ios, size: 16, - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ], ), - - const SizedBox(height: SpacingTokens.lg), - + + const SizedBox(height: SpacingTokens.md), + // Titre Text( title, style: AppTypography.badgeText.copyWith( - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), - + const SizedBox(height: SpacingTokens.sm), - + // Valeur Text( value, style: AppTypography.headerSmall.copyWith( - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, ), ), - + // Sous-titre optionnel if (subtitle != null) ...[ const SizedBox(height: SpacingTokens.sm), Text( subtitle!, style: AppTypography.subtitleSmall.copyWith( - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ], diff --git a/lib/shared/design_system/components/dashboard_activity_row.dart b/lib/shared/design_system/components/dashboard_activity_row.dart new file mode 100644 index 0000000..20788ce --- /dev/null +++ b/lib/shared/design_system/components/dashboard_activity_row.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import '../tokens/unionflow_colors.dart'; + +/// Ligne d'activité récente — style fintech compact identique au super_admin +/// Icône dans carré arrondi 28×28 + titre + description + timestamp +class DashboardActivityRow extends StatelessWidget { + final String title; + final String description; + final String timeAgo; + final IconData icon; + final Color color; + + const DashboardActivityRow({ + super.key, + required this.title, + required this.description, + required this.timeAgo, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: UnionFlowColors.border), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, size: 14, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: UnionFlowColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + description, + style: const TextStyle( + fontSize: 10, + color: UnionFlowColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + timeAgo, + style: const TextStyle(fontSize: 10, color: UnionFlowColors.textTertiary), + ), + ], + ), + ); + } + + /// Icône selon le type d'activité + static IconData iconFor(String type) { + switch (type) { + case 'member': + return Icons.person_add_rounded; + case 'event': + return Icons.event_rounded; + case 'contribution': + return Icons.payments_rounded; + default: + return Icons.circle_notifications_rounded; + } + } + + /// Couleur selon le type d'activité + static Color colorFor(String type) { + switch (type) { + case 'member': + return UnionFlowColors.unionGreen; + case 'event': + return UnionFlowColors.info; + case 'contribution': + return UnionFlowColors.gold; + default: + return UnionFlowColors.textSecondary; + } + } +} diff --git a/lib/shared/design_system/components/dashboard_event_row.dart b/lib/shared/design_system/components/dashboard_event_row.dart new file mode 100644 index 0000000..70c1315 --- /dev/null +++ b/lib/shared/design_system/components/dashboard_event_row.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import '../tokens/unionflow_colors.dart'; + +/// Ligne d'événement à venir — style fintech avec bordure gauche verte +/// Titre + date + countdown optionnel + participants optionnel +class DashboardEventRow extends StatelessWidget { + final String title; + final String date; + final String? daysUntil; + final String? participants; + + const DashboardEventRow({ + super.key, + required this.title, + required this.date, + this.daysUntil, + this.participants, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(8), + border: const Border( + left: BorderSide(color: UnionFlowColors.unionGreen, width: 3), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: UnionFlowColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + date, + style: const TextStyle( + fontSize: 10, + color: UnionFlowColors.textSecondary, + ), + ), + ], + ), + ), + if (daysUntil != null || participants != null) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (daysUntil != null) + Text( + daysUntil!, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: UnionFlowColors.unionGreen, + ), + ), + if (participants != null) ...[ + const SizedBox(height: 2), + Text( + participants!, + style: const TextStyle( + fontSize: 10, + color: UnionFlowColors.textTertiary, + ), + ), + ], + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/design_system/components/inputs/uf_dropdown_tile.dart b/lib/shared/design_system/components/inputs/uf_dropdown_tile.dart index b59ca2b..13b0cdf 100644 --- a/lib/shared/design_system/components/inputs/uf_dropdown_tile.dart +++ b/lib/shared/design_system/components/inputs/uf_dropdown_tile.dart @@ -48,7 +48,9 @@ class UFDropdownTile extends StatelessWidget { @override Widget build(BuildContext context) { - final effectiveBgColor = backgroundColor ?? AppColors.lightSurface; + final isDark = Theme.of(context).brightness == Brightness.dark; + final effectiveBgColor = backgroundColor ?? + (isDark ? AppColors.darkSurface : AppColors.lightSurface); final effectiveItemBuilder = itemBuilder ?? (item) => item.toString(); return Container( @@ -65,16 +67,16 @@ class UFDropdownTile extends StatelessWidget { title, style: AppTypography.bodyTextSmall.copyWith( fontWeight: FontWeight.w600, - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, ), ), ), Container( padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? AppColors.darkBackground : Colors.white, borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), - border: Border.all(color: AppColors.lightBorder), + border: Border.all(color: isDark ? AppColors.darkBorder : AppColors.lightBorder), ), child: DropdownButtonHideUnderline( child: DropdownButton( diff --git a/lib/shared/design_system/components/inputs/uf_switch_tile.dart b/lib/shared/design_system/components/inputs/uf_switch_tile.dart index 2e134d9..ccc9c5d 100644 --- a/lib/shared/design_system/components/inputs/uf_switch_tile.dart +++ b/lib/shared/design_system/components/inputs/uf_switch_tile.dart @@ -44,7 +44,9 @@ class UFSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { - final effectiveBgColor = backgroundColor ?? AppColors.lightSurface; + final isDark = Theme.of(context).brightness == Brightness.dark; + final effectiveBgColor = backgroundColor ?? + (isDark ? AppColors.darkSurface : AppColors.lightSurface); return Container( margin: const EdgeInsets.only(bottom: SpacingTokens.lg), @@ -63,13 +65,13 @@ class UFSwitchTile extends StatelessWidget { title, style: AppTypography.bodyTextSmall.copyWith( fontWeight: FontWeight.w600, - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, ), ), Text( subtitle, style: AppTypography.subtitleSmall.copyWith( - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ], diff --git a/lib/shared/design_system/components/uf_container.dart b/lib/shared/design_system/components/uf_container.dart index f604dd7..13a6d00 100644 --- a/lib/shared/design_system/components/uf_container.dart +++ b/lib/shared/design_system/components/uf_container.dart @@ -65,7 +65,7 @@ class UFContainer extends StatelessWidget { this.gradient, this.border, this.boxShadow, - }) : borderRadius = SpacingTokens.radiusLg; + }) : borderRadius = SpacingTokens.radiusMd; /// Container très arrondi const UFContainer.extraRounded({ @@ -81,7 +81,7 @@ class UFContainer extends StatelessWidget { this.gradient, this.border, this.boxShadow, - }) : borderRadius = SpacingTokens.radiusXl; + }) : borderRadius = SpacingTokens.radiusLg; /// Container avec ombre UFContainer.elevated({ @@ -96,8 +96,8 @@ class UFContainer extends StatelessWidget { this.constraints, this.gradient, this.border, - }) : borderRadius = SpacingTokens.radiusLg, - boxShadow = ShadowTokens.sm; + }) : borderRadius = SpacingTokens.radiusMd, + boxShadow = null; /// Container circulaire const UFContainer.circular({ diff --git a/lib/shared/design_system/components/uf_header.dart b/lib/shared/design_system/components/uf_header.dart index 2cec6eb..f50c874 100644 --- a/lib/shared/design_system/components/uf_header.dart +++ b/lib/shared/design_system/components/uf_header.dart @@ -28,13 +28,12 @@ class UFHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(SpacingTokens.xl), + padding: const EdgeInsets.all(SpacingTokens.lg), decoration: BoxDecoration( gradient: const LinearGradient( colors: [AppColors.primaryGreen, AppColors.brandGreenLight], ), - borderRadius: BorderRadius.circular(SpacingTokens.radiusLg), - boxShadow: ShadowTokens.primary, + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), ), child: Row( children: [ @@ -43,12 +42,12 @@ class UFHeader extends StatelessWidget { padding: const EdgeInsets.all(SpacingTokens.sm), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), ), child: Icon( icon, color: Colors.white, - size: 24, + size: 18, ), ), const SizedBox(width: SpacingTokens.lg), @@ -95,7 +94,7 @@ class UFHeader extends StatelessWidget { Container( decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), + borderRadius: BorderRadius.circular(SpacingTokens.radiusXs), ), child: IconButton( onPressed: onNotificationTap, @@ -111,7 +110,7 @@ class UFHeader extends StatelessWidget { Container( decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), + borderRadius: BorderRadius.circular(SpacingTokens.radiusXs), ), child: IconButton( onPressed: onSettingsTap, diff --git a/lib/shared/design_system/components/uf_page_header.dart b/lib/shared/design_system/components/uf_page_header.dart index 5d1e547..3a7f68c 100644 --- a/lib/shared/design_system/components/uf_page_header.dart +++ b/lib/shared/design_system/components/uf_page_header.dart @@ -34,6 +34,7 @@ class UFPageHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveIconColor = iconColor ?? AppColors.primaryGreen; return Column( @@ -59,30 +60,30 @@ class UFPageHeader extends StatelessWidget { ), ), const SizedBox(width: SpacingTokens.md), - + // Titre Expanded( child: Text( title, style: AppTypography.headerSmall.copyWith( - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, fontWeight: FontWeight.w600, ), ), ), - + // Actions if (actions != null) ...actions!, ], ), ), - + // Divider optionnel if (showDivider) - const Divider( + Divider( height: 1, thickness: 1, - color: AppColors.lightBorder, + color: isDark ? AppColors.darkBorder : AppColors.lightBorder, ), ], ); @@ -110,6 +111,7 @@ class UFPageHeaderWithStats extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveIconColor = iconColor ?? AppColors.primaryGreen; return Column( @@ -138,18 +140,18 @@ class UFPageHeaderWithStats extends StatelessWidget { ), ), const SizedBox(width: SpacingTokens.md), - + // Titre Expanded( child: Text( title, style: AppTypography.headerSmall.copyWith( - color: AppColors.textPrimaryLight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, fontWeight: FontWeight.w600, ), ), ), - + // Actions if (actions != null) ...actions!, ], @@ -172,7 +174,7 @@ class UFPageHeaderWithStats extends StatelessWidget { padding: EdgeInsets.only( right: isLast ? 0 : SpacingTokens.sm, ), - child: _buildStatItem(stat), + child: _buildStatItem(stat, isDark), ), ); }).toList(), @@ -180,16 +182,16 @@ class UFPageHeaderWithStats extends StatelessWidget { ), // Divider - const Divider( + Divider( height: 1, thickness: 1, - color: AppColors.lightBorder, + color: isDark ? AppColors.darkBorder : AppColors.lightBorder, ), ], ); } - Widget _buildStatItem(UFHeaderStat stat) { + Widget _buildStatItem(UFHeaderStat stat, bool isDark) { final effectiveColor = stat.color ?? AppColors.primaryGreen; return UFContainer.rounded( padding: const EdgeInsets.symmetric( @@ -211,7 +213,7 @@ class UFPageHeaderWithStats extends StatelessWidget { Text( stat.label, style: AppTypography.badgeText.copyWith( - color: AppColors.textSecondaryLight, + color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/shared/design_system/components/uf_section_header.dart b/lib/shared/design_system/components/uf_section_header.dart new file mode 100644 index 0000000..77d4a22 --- /dev/null +++ b/lib/shared/design_system/components/uf_section_header.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import '../tokens/unionflow_colors.dart'; + +/// En-tête de section dashboard — titre 13px w700 avec trailing optionnel +/// Style de référence : super_admin_dashboard._buildSectionHeader +class UFSectionHeader extends StatelessWidget { + final String title; + + /// Texte secondaire affiché à droite : "· trailing" + final String? trailing; + + const UFSectionHeader(this.title, {this.trailing, super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, + ), + ), + if (trailing != null && trailing!.isNotEmpty) ...[ + const SizedBox(width: 6), + Text( + '· $trailing', + style: const TextStyle( + fontSize: 11, + color: UnionFlowColors.textTertiary, + ), + ), + ], + ], + ); + } +} diff --git a/lib/shared/design_system/components/union_action_button.dart b/lib/shared/design_system/components/union_action_button.dart index 48b1c6d..5e2783e 100644 --- a/lib/shared/design_system/components/union_action_button.dart +++ b/lib/shared/design_system/components/union_action_button.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../tokens/unionflow_colors.dart'; /// Bouton d'action rapide UnionFlow +/// Style fintech : fond blanc, icône + texte colorés, bordure grise légère +/// Copie exacte du style _buildActionCell du super_admin_dashboard class UnionActionButton extends StatelessWidget { final IconData icon; final String label; @@ -20,36 +22,36 @@ class UnionActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + // backgroundColor sert d'accent (icône + texte), iconColor prend la priorité + final accentColor = iconColor ?? backgroundColor ?? UnionFlowColors.unionGreen; + + return InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(10), child: Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 8), decoration: BoxDecoration( - color: backgroundColor ?? UnionFlowColors.unionGreenPale, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: (backgroundColor ?? UnionFlowColors.unionGreenPale) - .withOpacity(0.2), - width: 1, - ), + color: UnionFlowColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.border), ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - icon, - size: 28, - color: iconColor ?? UnionFlowColors.unionGreen, - ), - const SizedBox(height: 8), - Text( - label, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, + Icon(icon, size: 16, color: accentColor), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: accentColor, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, ), ], ), @@ -73,7 +75,7 @@ class UnionActionGrid extends StatelessWidget { children: [ for (int i = 0; i < actions.length; i++) ...[ Expanded(child: actions[i]), - if (i < actions.length - 1) const SizedBox(width: 12), + if (i < actions.length - 1) const SizedBox(width: 10), ], ], ); diff --git a/lib/shared/design_system/components/union_balance_card.dart b/lib/shared/design_system/components/union_balance_card.dart index 1f39b45..760f77c 100644 --- a/lib/shared/design_system/components/union_balance_card.dart +++ b/lib/shared/design_system/components/union_balance_card.dart @@ -22,75 +22,87 @@ class UnionBalanceCard extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, - // Bordure dorée subtile en haut - border: const Border( - top: BorderSide( - color: UnionFlowColors.gold, - width: 3, - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + decoration: BoxDecoration( + color: UnionFlowColors.surface, + border: Border.all(color: UnionFlowColors.border), ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Label - Text( - label.toUpperCase(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textSecondary, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: 8), - - // Montant principal - Text( - amount, - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - color: UnionFlowColors.unionGreen, - height: 1.2, - ), - ), - - // Trend (optionnel) - if (trend != null) ...[ - const SizedBox(height: 10), - Row( - children: [ - Icon( - isTrendPositive == true - ? Icons.trending_up - : Icons.trending_down, - size: 16, - color: isTrendPositive == true - ? UnionFlowColors.success - : UnionFlowColors.error, - ), - const SizedBox(width: 4), - Text( - trend!, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isTrendPositive == true - ? UnionFlowColors.success - : UnionFlowColors.error, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 2, color: UnionFlowColors.gold), + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: UnionFlowColors.textSecondary, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + amount, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: UnionFlowColors.textPrimary, + height: 1, + letterSpacing: -0.5, + ), + ), + ], + ), ), - ), - ], + if (trend != null) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Icon( + isTrendPositive == true + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 11, + color: isTrendPositive == true + ? UnionFlowColors.success + : UnionFlowColors.error, + ), + const SizedBox(width: 2), + Text( + trend!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isTrendPositive == true + ? UnionFlowColors.success + : UnionFlowColors.error, + ), + ), + ], + ), + const Text( + 'ce mois', + style: TextStyle(fontSize: 9, color: UnionFlowColors.textTertiary), + ), + ], + ), + ], + ), ), ], - ], + ), ), ), ); diff --git a/lib/shared/design_system/components/union_glass_card.dart b/lib/shared/design_system/components/union_glass_card.dart index 8123236..f606eb4 100644 --- a/lib/shared/design_system/components/union_glass_card.dart +++ b/lib/shared/design_system/components/union_glass_card.dart @@ -26,25 +26,18 @@ class UnionGlassCard extends StatelessWidget { child: Container( margin: margin, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius ?? 16), + borderRadius: BorderRadius.circular(borderRadius ?? 10), border: Border.all( color: Colors.white.withOpacity(0.2), width: 1.5, ), - boxShadow: [ - BoxShadow( - color: UnionFlowColors.unionGreen.withOpacity(0.1), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(borderRadius ?? 16), + borderRadius: BorderRadius.circular(borderRadius ?? 10), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - padding: padding ?? const EdgeInsets.all(20), + padding: padding ?? const EdgeInsets.all(10), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, diff --git a/lib/shared/design_system/components/union_line_chart.dart b/lib/shared/design_system/components/union_line_chart.dart index 5fea09c..4bf4717 100644 --- a/lib/shared/design_system/components/union_line_chart.dart +++ b/lib/shared/design_system/components/union_line_chart.dart @@ -77,11 +77,10 @@ class UnionLineChart extends StatelessWidget { final effectiveGradientEnd = gradientEndColor ?? UnionFlowColors.unionGreen.withOpacity(0.0); return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -105,7 +104,7 @@ class UnionLineChart extends StatelessWidget { ), ), ], - const SizedBox(height: 20), + const SizedBox(height: 10), // Chart SizedBox( diff --git a/lib/shared/design_system/components/union_pie_chart.dart b/lib/shared/design_system/components/union_pie_chart.dart index 112215f..1f555ae 100644 --- a/lib/shared/design_system/components/union_pie_chart.dart +++ b/lib/shared/design_system/components/union_pie_chart.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import '../tokens/unionflow_colors.dart'; -/// Graphique circulaire UnionFlow - Pour afficher des répartitions +/// Graphique circulaire UnionFlow — petit donut compact avec légende latérale +/// Layout horizontal : donut 80×80 à gauche + légende à droite +/// Style uniforme avec les autres cards du design system (border + borderRadius) class UnionPieChart extends StatelessWidget { final List sections; final String title; @@ -19,47 +21,110 @@ class UnionPieChart extends StatelessWidget { @override Widget build(BuildContext context) { + // Forcer compact : radius 14, pas de titre dans le donut + final compactSections = sections + .map((s) => s.copyWith(radius: 14, showTitle: false)) + .toList(); + return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: UnionFlowColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header - Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textPrimary, - ), + Row( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: UnionFlowColors.textPrimary, + ), + ), + const Spacer(), + if (subtitle != null) + Text( + subtitle!, + style: const TextStyle( + fontSize: 10, + color: UnionFlowColors.textTertiary, + ), + ), + ], ), - if (subtitle != null) ...[ - const SizedBox(height: 4), - Text( - subtitle!, - style: const TextStyle( - fontSize: 11, - color: UnionFlowColors.textSecondary, - ), - ), - ], - const SizedBox(height: 20), + const SizedBox(height: 10), - // Chart - SizedBox( - height: 180, - child: PieChart( - PieChartData( - sectionsSpace: 2, - centerSpaceRadius: centerSpaceRadius ?? 50, - sections: sections, + // Donut + légende côte à côte + Row( + children: [ + // Petit donut + SizedBox( + width: 80, + height: 80, + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: centerSpaceRadius ?? 22, + sections: compactSections, + ), + ), ), - ), + const SizedBox(width: 14), + + // Légende + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: sections.map((s) { + // Le title de la section peut contenir '\n' ex: '50%\nActifs' + final parts = s.title.split('\n'); + final pct = parts.isNotEmpty ? parts[0] : ''; + final label = parts.length > 1 ? parts[1] : s.title; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: s.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 10, + color: UnionFlowColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + pct, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: s.color, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], ), ], ), @@ -79,8 +144,9 @@ class UnionPieChartSection { return PieChartSectionData( color: color, value: value, - title: showTitle ? title : '', + title: title, radius: radius, + showTitle: showTitle, titleStyle: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, diff --git a/lib/shared/design_system/components/union_progress_card.dart b/lib/shared/design_system/components/union_progress_card.dart index e25f0e9..3b5dfad 100644 --- a/lib/shared/design_system/components/union_progress_card.dart +++ b/lib/shared/design_system/components/union_progress_card.dart @@ -25,11 +25,10 @@ class UnionProgressCard extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -43,7 +42,7 @@ class UnionProgressCard extends StatelessWidget { color: UnionFlowColors.textPrimary, ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), // Progress bar Stack( @@ -69,13 +68,6 @@ class UnionProgressCard extends StatelessWidget { ], ), borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: effectiveColor.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], ), ), ), diff --git a/lib/shared/design_system/components/union_stat_widget.dart b/lib/shared/design_system/components/union_stat_widget.dart index 77e50e7..a5b828d 100644 --- a/lib/shared/design_system/components/union_stat_widget.dart +++ b/lib/shared/design_system/components/union_stat_widget.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import '../tokens/unionflow_colors.dart'; -/// Widget de statistique compacte avec icône et tendance +/// Widget de statistique compacte — style identique à _buildKpiCell du super admin +/// fond blanc, bordure gauche colorée, icône + valeur + label +/// [compact] réduit le padding vertical pour les grilles très plates class UnionStatWidget extends StatelessWidget { final String label; final String value; @@ -10,6 +12,9 @@ class UnionStatWidget extends StatelessWidget { final String? trend; final bool? isTrendUp; + /// Mode ultra-compact : padding vertical réduit, espacement minimal + final bool compact; + const UnionStatWidget({ super.key, required this.label, @@ -18,86 +23,65 @@ class UnionStatWidget extends StatelessWidget { required this.color, this.trend, this.isTrendUp, + this.compact = false, }); @override Widget build(BuildContext context) { + final EdgeInsets pad = compact + ? const EdgeInsets.symmetric(horizontal: 8, vertical: 5) + : const EdgeInsets.all(6); + return Container( - padding: const EdgeInsets.all(16), + padding: pad, decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, - border: Border( - left: BorderSide( - color: color, - width: 4, - ), - ), + borderRadius: BorderRadius.circular(10), + border: Border(left: BorderSide(color: color, width: 3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Icon - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 20, color: color), - ), - const SizedBox(height: 12), - - // Value - Text( - value, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: color, - ), - ), - const SizedBox(height: 4), - - // Label - Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: UnionFlowColors.textSecondary, - ), - ), - - // Trend - if (trend != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon( - isTrendUp == true - ? Icons.trending_up - : Icons.trending_down, - size: 14, - color: isTrendUp == true - ? UnionFlowColors.success - : UnionFlowColors.error, - ), - const SizedBox(width: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 13, color: color), + if (trend != null) ...[ + const Spacer(), Text( trend!, style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isTrendUp == true - ? UnionFlowColors.success - : UnionFlowColors.error, + fontSize: 8, + fontWeight: FontWeight.w700, + color: color, ), ), ], + ], + ), + SizedBox(height: compact ? 2 : 4), + Text( + value, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: color, + letterSpacing: -0.3, + height: 1, ), - ], + ), + SizedBox(height: compact ? 1 : 2), + Text( + label, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: UnionFlowColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ); diff --git a/lib/shared/design_system/components/union_transaction_tile.dart b/lib/shared/design_system/components/union_transaction_tile.dart index 60a65e4..21adc33 100644 --- a/lib/shared/design_system/components/union_transaction_tile.dart +++ b/lib/shared/design_system/components/union_transaction_tile.dart @@ -153,11 +153,10 @@ class UnionTransactionCard extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/shared/design_system/components/union_unified_account_card.dart b/lib/shared/design_system/components/union_unified_account_card.dart index 5021935..7dc2635 100644 --- a/lib/shared/design_system/components/union_unified_account_card.dart +++ b/lib/shared/design_system/components/union_unified_account_card.dart @@ -35,14 +35,7 @@ class UnionUnifiedAccountCard extends StatelessWidget { UnionFlowColors.unionGreen.withOpacity(0.85), ], ), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: UnionFlowColors.unionGreen.withOpacity(0.35), - offset: const Offset(0, 10), - blurRadius: 20, - ), - ], + borderRadius: BorderRadius.circular(12), ), child: Stack( children: [ @@ -58,7 +51,7 @@ class UnionUnifiedAccountCard extends StatelessWidget { ), Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -95,7 +88,7 @@ class UnionUnifiedAccountCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white30), ), child: Text( @@ -111,8 +104,8 @@ class UnionUnifiedAccountCard extends StatelessWidget { ], ), - const SizedBox(height: 32), - + const SizedBox(height: 16), + // Solde Total Disponible const Text( 'Solde Total Disponible', @@ -129,15 +122,15 @@ class UnionUnifiedAccountCard extends StatelessWidget { soldeTotal, style: const TextStyle( color: Colors.white, - fontSize: 36, + fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), ), - const SizedBox(height: 32), - + const SizedBox(height: 16), + // Grille de détails Row( children: [ @@ -147,8 +140,8 @@ class UnionUnifiedAccountCard extends StatelessWidget { ], ), - const SizedBox(height: 20), - + const SizedBox(height: 10), + // Barre d'engagement Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/shared/design_system/components/user_identity_card.dart b/lib/shared/design_system/components/user_identity_card.dart new file mode 100644 index 0000000..efbde2b --- /dev/null +++ b/lib/shared/design_system/components/user_identity_card.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import '../tokens/unionflow_colors.dart'; + +/// Carte identité utilisateur — header uniforme pour tous les dashboards +/// Gradient coloré + initiales + nom + sous-titre + badge rôle +class UserIdentityCard extends StatelessWidget { + final String initials; + final String name; + final String subtitle; + final String badgeLabel; + final Gradient gradient; + final Color accentColor; + + /// true = fond clair → texte sombre, badge avec fond coloré + /// false (défaut) = fond sombre → texte blanc, badge blanc + texte coloré + final bool lightBackground; + + /// Afficher la bordure supérieure colorée (accentColor) + final bool showTopBorder; + + const UserIdentityCard({ + super.key, + required this.initials, + required this.name, + required this.subtitle, + required this.badgeLabel, + required this.gradient, + required this.accentColor, + this.lightBackground = false, + this.showTopBorder = true, + }); + + @override + Widget build(BuildContext context) { + final textColor = + lightBackground ? UnionFlowColors.textPrimary : Colors.white; + final subtitleColor = lightBackground + ? UnionFlowColors.textSecondary + : Colors.white.withOpacity(0.85); + final avatarBg = lightBackground + ? accentColor.withOpacity(0.15) + : Colors.white.withOpacity(0.25); + final avatarTextColor = lightBackground ? accentColor : Colors.white; + final badgeBg = lightBackground ? accentColor : Colors.white; + final badgeTextColor = lightBackground ? Colors.white : accentColor; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(10), + border: showTopBorder + ? Border(top: BorderSide(color: accentColor, width: 3)) + : null, + boxShadow: showTopBorder + ? [ + BoxShadow( + color: accentColor.withOpacity(0.25), + blurRadius: 10, + offset: const Offset(0, 3), + ) + ] + : null, + ), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: avatarBg, + child: Text( + initials, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: avatarTextColor, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: TextStyle(fontSize: 11, color: subtitleColor), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + badgeLabel, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + color: badgeTextColor, + letterSpacing: 0.8, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/design_system/theme/app_theme_sophisticated.dart b/lib/shared/design_system/theme/app_theme_sophisticated.dart index 82d80dc..37a4da0 100644 --- a/lib/shared/design_system/theme/app_theme_sophisticated.dart +++ b/lib/shared/design_system/theme/app_theme_sophisticated.dart @@ -27,7 +27,7 @@ class AppThemeSophisticated { // Couleurs principales colorScheme: _lightColorScheme, - // Typographie + // Typographie (Playfair Display display + Inter body) textTheme: _textTheme, // Configuration de l'AppBar @@ -85,19 +85,34 @@ class AppThemeSophisticated { ); } - /// Thème sombre (suit le système ou sélection manuelle) + /// Thème sombre — Vert Ardoise (#1A2E1A) static ThemeData get darkTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, - colorScheme: ColorScheme.dark( - primary: ColorTokens.primary, - onPrimary: Colors.white, - surface: const Color(0xFF121212), - onSurface: Colors.white, - error: ColorTokens.error, + colorScheme: const ColorScheme.dark( + primary: Color(0xFF4CAF50), // Vert clair sur fond sombre + onPrimary: Color(0xFF003908), + primaryContainer: Color(0xFF1E3A1E), + onPrimaryContainer: Color(0xFFB9F0B9), + secondary: Color(0xFFA5D6A7), + onSecondary: Color(0xFF002106), + surface: Color(0xFF1A2E1A), // Vert ardoise + onSurface: Color(0xFFE0F2E0), + surfaceContainerHighest: Color(0xFF243824), + onSurfaceVariant: Color(0xFF90B890), + error: Color(0xFFEF4444), + onError: Colors.white, + outline: Color(0xFF3A5E3A), + shadow: Color(0xFF0F1A0F), + ), + scaffoldBackgroundColor: const Color(0xFF0F1A0F), + appBarTheme: const AppBarTheme( + elevation: 0, + backgroundColor: Colors.transparent, + foregroundColor: Color(0xFFE0F2E0), + surfaceTintColor: Colors.transparent, ), - scaffoldBackgroundColor: const Color(0xFF121212), ); } @@ -154,27 +169,27 @@ class AppThemeSophisticated { // THÈME TYPOGRAPHIQUE // ═══════════════════════════════════════════════════════════════════════════ - static const TextTheme _textTheme = TextTheme( - // Display styles + static TextTheme get _textTheme => TextTheme( + // Display styles — Playfair Display (GoogleFonts, non-const) displayLarge: TypographyTokens.displayLarge, displayMedium: TypographyTokens.displayMedium, displaySmall: TypographyTokens.displaySmall, - + // Headline styles headlineLarge: TypographyTokens.headlineLarge, headlineMedium: TypographyTokens.headlineMedium, headlineSmall: TypographyTokens.headlineSmall, - + // Title styles titleLarge: TypographyTokens.titleLarge, titleMedium: TypographyTokens.titleMedium, titleSmall: TypographyTokens.titleSmall, - + // Label styles labelLarge: TypographyTokens.labelLarge, labelMedium: TypographyTokens.labelMedium, labelSmall: TypographyTokens.labelSmall, - + // Body styles bodyLarge: TypographyTokens.bodyLarge, bodyMedium: TypographyTokens.bodyMedium, diff --git a/lib/shared/design_system/tokens/app_colors.dart b/lib/shared/design_system/tokens/app_colors.dart index f2ff88f..aef9848 100644 --- a/lib/shared/design_system/tokens/app_colors.dart +++ b/lib/shared/design_system/tokens/app_colors.dart @@ -1,34 +1,35 @@ import 'package:flutter/material.dart'; -/// UnionFlow Mobile App - Couleurs Globales (Strict DRY) -/// Palette principale: Vert, Blanc (Jour), Noir (Nuit). +/// UnionFlow Mobile App - Couleurs Globales (DRY) +/// Palette : Vert Forêt (Jour) / Vert Ardoise (Nuit) class AppColors { // --- Branding --- - static const Color primaryGreen = Color(0xFF17BF63); // Vert vibrant style social (Fb/Tw) - Corrigé. - static const Color brandGreen = Color(0xFF2E7D32); // Vert professionnel et lisible + static const Color primaryGreen = Color(0xFF2E7D32); // Vert forêt professionnel + static const Color brandGreen = Color(0xFF1B5E20); // Vert foncé / gradient top static const Color brandGreenLight = Color(0xFF4CAF50); // Vert d'accentuation - + static const Color brandMint = Color(0xFFA5D6A7); // Vert menthe / secondaire + // --- Mode Jour (Light) --- - static const Color lightBackground = Color(0xFFFFFFFF); // Blanc pur - static const Color lightSurface = Color(0xFFF5F8FA); // Gris extrêmement léger pour séparer les cards - static const Color lightBorder = Color(0xFFE1E8ED); // Bordures style Twitter - - // --- Mode Nuit (Dark OLED) --- - static const Color darkBackground = Color(0xFF000000); // Noir pur pour OLED - static const Color darkSurface = Color(0xFF15202B); // Gris sombre typique Twitter/Fb Dark - static const Color darkBorder = Color(0xFF38444D); // Bordure discrète sombre + static const Color lightBackground = Color(0xFFF1F8E9); // Teinte verte très légère + static const Color lightSurface = Color(0xFFFFFFFF); // Blanc pur pour les cartes + static const Color lightBorder = Color(0xFFC8E6C9); // Bordure vert pâle + + // --- Mode Nuit (Dark) --- + static const Color darkBackground = Color(0xFF0F1A0F); // Vert noir profond + static const Color darkSurface = Color(0xFF1A2E1A); // Vert ardoise + static const Color darkBorder = Color(0xFF3A5E3A); // Bordure sombre // --- Texte --- - static const Color textPrimaryLight = Color(0xFF14171A); // Presque noir - static const Color textSecondaryLight = Color(0xFF657786); // Gris texte - static const Color textPrimaryDark = Color(0xFFE1E8ED); // Presque blanc - static const Color textSecondaryDark = Color(0xFF8899A6); // Gris clair nuit + static const Color textPrimaryLight = Color(0xFF1C2B1C); // Vert très foncé / quasi noir + static const Color textSecondaryLight = Color(0xFF4E6B4E); // Vert gris moyen + static const Color textPrimaryDark = Color(0xFFE0F2E0); // Blanc verdâtre + static const Color textSecondaryDark = Color(0xFF90B890); // Vert gris clair - // --- Sémantique (Succès, Erreur, Info) --- - static const Color error = Color(0xFFE0245E); // Rouge vif - static const Color success = Color(0xFF17BF63); // Vert validation - static const Color warning = Color(0xFFFFAD1F); // Orange - static const Color info = Color(0xFF1DA1F2); // Bleu info + // --- Sémantique --- + static const Color error = Color(0xFFDC2626); + static const Color success = Color(0xFF2E7D32); + static const Color warning = Color(0xFFF59E0B); + static const Color info = Color(0xFF0288D1); // --- Utilitaires --- static const Color transparent = Colors.transparent; diff --git a/lib/shared/design_system/tokens/app_typography.dart b/lib/shared/design_system/tokens/app_typography.dart index 7ec901b..911da13 100644 --- a/lib/shared/design_system/tokens/app_typography.dart +++ b/lib/shared/design_system/tokens/app_typography.dart @@ -1,47 +1,162 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; - -/// UnionFlow Mobile App - Typographie Globale (Ultra Minimaliste) -/// RÈGLE : AUCUN gros titre. Tailles limitées entre 10px et 14px pour maximiser l'information. +/// UnionFlow Mobile App - Typographie Globale +/// Roboto (Google Fonts) — cohérence cross-platform class AppTypography { - static const String _fontFamily = 'Roboto'; // Peut être changé pour 'Inter' si ajouté au pubspec.yaml + static const String _fontFamily = 'Roboto'; - // --- Titres (Max 14px) --- - static const TextStyle headerSmall = TextStyle( + // --- Display / Titres principaux (Roboto via GoogleFonts) --- + + static TextStyle get displayLarge => GoogleFonts.roboto( + fontSize: 32.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ); + + static TextStyle get displayMedium => GoogleFonts.roboto( + fontSize: 28.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.25, + height: 1.25, + ); + + static TextStyle get displaySmall => GoogleFonts.roboto( + fontSize: 24.0, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.3, + ); + + // --- Titres de sections (Inter SemiBold) --- + + static const TextStyle headerLarge = TextStyle( fontFamily: _fontFamily, - fontSize: 14.0, - fontWeight: FontWeight.w700, // Bold + fontSize: 22.0, + fontWeight: FontWeight.w700, letterSpacing: -0.2, + height: 1.27, ); - // --- Corps de texte (Max 12px) --- + /// Alias historique — conservé pour compatibilité + static const TextStyle headerSmall = TextStyle( + fontFamily: _fontFamily, + fontSize: 18.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + height: 1.3, + ); + + static const TextStyle titleMedium = TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + height: 1.5, + ); + + static const TextStyle titleSmall = TextStyle( + fontFamily: _fontFamily, + fontSize: 14.0, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + height: 1.43, + ); + + // --- Corps de texte --- + + static const TextStyle bodyLarge = TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + letterSpacing: 0.3, + height: 1.5, + ); + + static const TextStyle bodyMedium = TextStyle( + fontFamily: _fontFamily, + fontSize: 14.0, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + height: 1.43, + ); + + /// Alias historique — conservé pour compatibilité static const TextStyle bodyTextSmall = TextStyle( fontFamily: _fontFamily, - fontSize: 12.0, - fontWeight: FontWeight.w400, // Regular + fontSize: 13.0, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, height: 1.4, ); - // --- Boutons et Actions (Max 13px) --- + // --- Actions et boutons --- + static const TextStyle actionText = TextStyle( fontFamily: _fontFamily, - fontSize: 13.0, - fontWeight: FontWeight.w600, // SemiBold + fontSize: 15.0, + fontWeight: FontWeight.w600, letterSpacing: 0.1, ); - // --- Sous-titres, dates, labels (Max 11px) --- - static const TextStyle subtitleSmall = TextStyle( + static const TextStyle buttonLabel = TextStyle( fontFamily: _fontFamily, - fontSize: 11.0, - fontWeight: FontWeight.w300, // Light - letterSpacing: 0.2, + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, ); - // --- Badges, Piles, Métriques très denses (Max 10px) --- + // --- Sous-titres et labels --- + + /// Alias historique — conservé pour compatibilité + static const TextStyle subtitleSmall = TextStyle( + fontFamily: _fontFamily, + fontSize: 12.0, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, + height: 1.4, + ); + + static const TextStyle labelMedium = TextStyle( + fontFamily: _fontFamily, + fontSize: 12.0, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, + height: 1.33, + ); + + // --- Badges, métriques denses --- + + /// Alias historique — conservé pour compatibilité static const TextStyle badgeText = TextStyle( fontFamily: _fontFamily, - fontSize: 10.0, - fontWeight: FontWeight.w500, // Medium + fontSize: 11.0, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ); + + static const TextStyle caption = TextStyle( + fontFamily: _fontFamily, + fontSize: 11.0, + fontWeight: FontWeight.w400, + letterSpacing: 0.4, + height: 1.45, + ); + + // --- Navigation --- + + static const TextStyle navLabel = TextStyle( + fontFamily: _fontFamily, + fontSize: 11.0, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ); + + static const TextStyle navLabelSelected = TextStyle( + fontFamily: _fontFamily, + fontSize: 11.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, ); } diff --git a/lib/shared/design_system/tokens/color_tokens.dart b/lib/shared/design_system/tokens/color_tokens.dart index fe58f83..4a9b45e 100644 --- a/lib/shared/design_system/tokens/color_tokens.dart +++ b/lib/shared/design_system/tokens/color_tokens.dart @@ -1,11 +1,8 @@ /// Design Tokens - Couleurs UnionFlow /// -/// Palette de couleurs Bleu Roi + Bleu Pétrole -/// Inspirée des tendances UI/UX 2024-2025 -/// Basée sur les principes de Material Design 3 -/// -/// MODE JOUR: Bleu Roi (#4169E1) - Royal Blue -/// MODE NUIT: Bleu Pétrole (#2C5F6F) - Petroleum Blue +/// Palette Vert Zen / Santé — Professionnelle et apaisante +/// MODE JOUR : Vert Forêt (#2E7D32) — Forest Green +/// MODE NUIT : Vert Ardoise (#1A2E1A) — Slate Green library color_tokens; import 'package:flutter/material.dart'; @@ -15,180 +12,176 @@ class ColorTokens { ColorTokens._(); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS PRIMAIRES - MODE JOUR (Bleu Roi) + // COULEURS PRIMAIRES - MODE JOUR (Vert Forêt) // ═══════════════════════════════════════════════════════════════════════════ - /// Couleur primaire principale - Bleu Roi (Royal Blue) - static const Color primary = Color(0xFF4169E1); // Bleu roi - static const Color primaryLight = Color(0xFF6B8EF5); // Bleu roi clair - static const Color primaryDark = Color(0xFF2952C8); // Bleu roi sombre - static const Color primaryContainer = Color(0xFFE3ECFF); // Container bleu roi - static const Color onPrimary = Color(0xFFFFFFFF); // Texte sur primaire (blanc) - static const Color onPrimaryContainer = Color(0xFF001A41); // Texte sur container + /// Couleur primaire principale - Vert Forêt + static const Color primary = Color(0xFF2E7D32); + static const Color primaryLight = Color(0xFF4CAF50); + static const Color primaryDark = Color(0xFF1B5E20); + static const Color primaryContainer = Color(0xFFE8F5E9); + static const Color onPrimary = Color(0xFFFFFFFF); + static const Color onPrimaryContainer = Color(0xFF003908); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS PRIMAIRES - MODE NUIT (Bleu Pétrole) + // COULEURS PRIMAIRES - MODE NUIT (Vert Ardoise) // ═══════════════════════════════════════════════════════════════════════════ - /// Couleur primaire mode nuit - Bleu Pétrole - static const Color primaryDarkMode = Color(0xFF2C5F6F); // Bleu pétrole - static const Color primaryLightDarkMode = Color(0xFF3D7A8C); // Bleu pétrole clair - static const Color primaryDarkDarkMode = Color(0xFF1B4D5C); // Bleu pétrole sombre - static const Color primaryContainerDarkMode = Color(0xFF1E3A44); // Container mode nuit - static const Color onPrimaryDarkMode = Color(0xFFE5E7EB); // Texte sur primaire (gris clair) + static const Color primaryDarkMode = Color(0xFF1A2E1A); + static const Color primaryLightDarkMode = Color(0xFF2E4D2E); + static const Color primaryDarkDarkMode = Color(0xFF0F1A0F); + static const Color primaryContainerDarkMode = Color(0xFF1E3A1E); + static const Color onPrimaryDarkMode = Color(0xFFE0F2E0); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS SECONDAIRES - Indigo Moderne + // COULEURS SECONDAIRES - Vert Menthe / Sauge // ═══════════════════════════════════════════════════════════════════════════ - static const Color secondary = Color(0xFF6366F1); // Indigo moderne - static const Color secondaryLight = Color(0xFF8B8FF6); // Indigo clair - static const Color secondaryDark = Color(0xFF4F46E5); // Indigo sombre - static const Color secondaryContainer = Color(0xFFE0E7FF); // Container indigo + static const Color secondary = Color(0xFF66BB6A); + static const Color secondaryLight = Color(0xFFA5D6A7); + static const Color secondaryDark = Color(0xFF388E3C); + static const Color secondaryContainer = Color(0xFFC8E6C9); static const Color onSecondary = Color(0xFFFFFFFF); - static const Color onSecondaryContainer = Color(0xFF1E1B3A); + static const Color onSecondaryContainer = Color(0xFF002106); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS TERTIAIRES - Vert Émeraude + // COULEURS TERTIAIRES - Vert Lime / Accent // ═══════════════════════════════════════════════════════════════════════════ - static const Color tertiary = Color(0xFF10B981); // Vert émeraude - static const Color tertiaryLight = Color(0xFF34D399); // Vert clair - static const Color tertiaryDark = Color(0xFF059669); // Vert sombre - static const Color tertiaryContainer = Color(0xFFD1FAE5); // Container vert + static const Color tertiary = Color(0xFF8BC34A); + static const Color tertiaryLight = Color(0xFFAED581); + static const Color tertiaryDark = Color(0xFF558B2F); + static const Color tertiaryContainer = Color(0xFFDCEDC8); static const Color onTertiary = Color(0xFFFFFFFF); - static const Color onTertiaryContainer = Color(0xFF002114); + static const Color onTertiaryContainer = Color(0xFF1B3A00); // ═══════════════════════════════════════════════════════════════════════════ // COULEURS NEUTRES - MODE JOUR // ═══════════════════════════════════════════════════════════════════════════ - static const Color surface = Color(0xFFFFFFFF); // Surface principale (blanc) - static const Color surfaceVariant = Color(0xFFF8F9FA); // Surface variante (gris très clair) - static const Color surfaceContainer = Color(0xFFFFFFFF); // Container surface - static const Color surfaceContainerHigh = Color(0xFFF8F9FA); // Container élevé - static const Color surfaceContainerHighest = Color(0xFFE5E7EB); // Container max - static const Color background = Color(0xFFF8F9FA); // Background général + static const Color surface = Color(0xFFFFFFFF); + static const Color surfaceVariant = Color(0xFFF1F8E9); + static const Color surfaceContainer = Color(0xFFFFFFFF); + static const Color surfaceContainerHigh = Color(0xFFF1F8E9); + static const Color surfaceContainerHighest = Color(0xFFDCEDC8); + static const Color background = Color(0xFFF1F8E9); - static const Color onSurface = Color(0xFF1F2937); // Texte principal (gris très foncé) - static const Color onSurfaceVariant = Color(0xFF6B7280); // Texte secondaire (gris moyen) - static const Color textSecondary = Color(0xFF6B7280); // Texte secondaire (alias) - static const Color outline = Color(0xFFD1D5DB); // Bordures - static const Color outlineVariant = Color(0xFFE5E7EB); // Bordures claires + static const Color onSurface = Color(0xFF1C2B1C); + static const Color onSurfaceVariant = Color(0xFF4E6B4E); + static const Color textSecondary = Color(0xFF4E6B4E); + static const Color outline = Color(0xFFC8E6C9); + static const Color outlineVariant = Color(0xFFDCEDC8); // ═══════════════════════════════════════════════════════════════════════════ // COULEURS NEUTRES - MODE NUIT // ═══════════════════════════════════════════════════════════════════════════ - static const Color surfaceDarkMode = Color(0xFF1E1E1E); // Surface principale (gris très sombre) - static const Color surfaceVariantDarkMode = Color(0xFF2C2C2C); // Surface variante - static const Color backgroundDarkMode = Color(0xFF121212); // Background général (noir profond) + static const Color surfaceDarkMode = Color(0xFF1A2E1A); + static const Color surfaceVariantDarkMode = Color(0xFF243824); + static const Color backgroundDarkMode = Color(0xFF0F1A0F); - static const Color onSurfaceDarkMode = Color(0xFFE5E7EB); // Texte principal (gris très clair) - static const Color onSurfaceVariantDarkMode = Color(0xFF9CA3AF); // Texte secondaire (gris moyen) - static const Color outlineDarkMode = Color(0xFF4B5563); // Bordures mode nuit + static const Color onSurfaceDarkMode = Color(0xFFE0F2E0); + static const Color onSurfaceVariantDarkMode = Color(0xFF90B890); + static const Color outlineDarkMode = Color(0xFF3A5E3A); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS SÉMANTIQUES - États et feedback + // COULEURS SÉMANTIQUES // ═══════════════════════════════════════════════════════════════════════════ - - /// Couleurs de succès - static const Color success = Color(0xFF10B981); // Vert succès - static const Color successLight = Color(0xFF34D399); // Vert clair - static const Color successDark = Color(0xFF059669); // Vert sombre - static const Color successContainer = Color(0xFFECFDF5); // Container succès + + static const Color success = Color(0xFF2E7D32); + static const Color successLight = Color(0xFF4CAF50); + static const Color successDark = Color(0xFF1B5E20); + static const Color successContainer = Color(0xFFE8F5E9); static const Color onSuccess = Color(0xFFFFFFFF); - static const Color onSuccessContainer = Color(0xFF002114); + static const Color onSuccessContainer = Color(0xFF003908); - /// Couleurs d'erreur - static const Color error = Color(0xFFDC2626); // Rouge erreur - static const Color errorLight = Color(0xFFEF4444); // Rouge clair - static const Color errorDark = Color(0xFFB91C1C); // Rouge sombre - static const Color errorContainer = Color(0xFFFEF2F2); // Container erreur + static const Color error = Color(0xFFDC2626); + static const Color errorLight = Color(0xFFEF4444); + static const Color errorDark = Color(0xFFB91C1C); + static const Color errorContainer = Color(0xFFFEF2F2); static const Color onError = Color(0xFFFFFFFF); static const Color onErrorContainer = Color(0xFF410002); - /// Couleurs d'avertissement - static const Color warning = Color(0xFFF59E0B); // Orange avertissement - static const Color warningLight = Color(0xFFFBBF24); // Orange clair - static const Color warningDark = Color(0xFFD97706); // Orange sombre - static const Color warningContainer = Color(0xFFFEF3C7); // Container avertissement + static const Color warning = Color(0xFFF59E0B); + static const Color warningLight = Color(0xFFFBBF24); + static const Color warningDark = Color(0xFFD97706); + static const Color warningContainer = Color(0xFFFEF3C7); static const Color onWarning = Color(0xFFFFFFFF); static const Color onWarningContainer = Color(0xFF2D1B00); - /// Couleurs d'information - static const Color info = Color(0xFF0EA5E9); // Bleu info - static const Color infoLight = Color(0xFF38BDF8); // Bleu clair - static const Color infoDark = Color(0xFF0284C7); // Bleu sombre - static const Color infoContainer = Color(0xFFE0F2FE); // Container info + static const Color info = Color(0xFF0288D1); + static const Color infoLight = Color(0xFF29B6F6); + static const Color infoDark = Color(0xFF01579B); + static const Color infoContainer = Color(0xFFE1F5FE); static const Color onInfo = Color(0xFFFFFFFF); static const Color onInfoContainer = Color(0xFF001D36); // ═══════════════════════════════════════════════════════════════════════════ - // COULEURS SPÉCIALISÉES - Interface avancée + // COULEURS DE NAVIGATION // ═══════════════════════════════════════════════════════════════════════════ - - /// Couleurs de navigation - Mode Jour + static const Color navigationBackground = Color(0xFFFFFFFF); - static const Color navigationSelected = Color(0xFF4169E1); // Bleu roi - static const Color navigationUnselected = Color(0xFF6B7280); - static const Color navigationIndicator = Color(0xFF4169E1); // Bleu roi + static const Color navigationSelected = Color(0xFF2E7D32); + static const Color navigationUnselected = Color(0xFF4E6B4E); + static const Color navigationIndicator = Color(0xFFE8F5E9); - /// Couleurs de navigation - Mode Nuit - static const Color navigationBackgroundDarkMode = Color(0xFF1E1E1E); - static const Color navigationSelectedDarkMode = Color(0xFF2C5F6F); // Bleu pétrole - static const Color navigationUnselectedDarkMode = Color(0xFF9CA3AF); - static const Color navigationIndicatorDarkMode = Color(0xFF2C5F6F); // Bleu pétrole + static const Color navigationBackgroundDarkMode = Color(0xFF1A2E1A); + static const Color navigationSelectedDarkMode = Color(0xFFA5D6A7); + static const Color navigationUnselectedDarkMode = Color(0xFF90B890); + static const Color navigationIndicatorDarkMode = Color(0xFF2E4D2E); - /// Couleurs d'élévation et ombres - static const Color shadow = Color(0x1A000000); // Ombre légère - static const Color shadowMedium = Color(0x33000000); // Ombre moyenne - static const Color shadowHigh = Color(0x4D000000); // Ombre forte + // ═══════════════════════════════════════════════════════════════════════════ + // OMBRES ET EFFETS + // ═══════════════════════════════════════════════════════════════════════════ - /// Couleurs de glassmorphism (tendance 2024-2025) - static const Color glassBackground = Color(0x80FFFFFF); // Fond verre - static const Color glassBorder = Color(0x33FFFFFF); // Bordure verre - static const Color glassOverlay = Color(0x0DFFFFFF); // Overlay verre + static const Color shadow = Color(0x1A1C2B1C); + static const Color shadowMedium = Color(0x331C2B1C); + static const Color shadowHigh = Color(0x4D1C2B1C); + + static const Color glassBackground = Color(0x80FFFFFF); + static const Color glassBorder = Color(0x33FFFFFF); + static const Color glassOverlay = Color(0x0DFFFFFF); + + // ═══════════════════════════════════════════════════════════════════════════ + // GRADIENTS + // ═══════════════════════════════════════════════════════════════════════════ - /// Couleurs de gradient - Mode Jour (Bleu Roi) static const List primaryGradient = [ - Color(0xFF4169E1), // Bleu roi - Color(0xFF6B8EF5), // Bleu roi clair + Color(0xFF1B5E20), + Color(0xFF2E7D32), + Color(0xFF388E3C), ]; - /// Couleurs de gradient - Mode Nuit (Bleu Pétrole) static const List primaryGradientDarkMode = [ - Color(0xFF2C5F6F), // Bleu pétrole - Color(0xFF3D7A8C), // Bleu pétrole clair + Color(0xFF0F1A0F), + Color(0xFF1A2E1A), + Color(0xFF243824), ]; static const List secondaryGradient = [ - Color(0xFF6366F1), // Indigo - Color(0xFF8B8FF6), // Indigo clair + Color(0xFF388E3C), + Color(0xFF66BB6A), ]; static const List successGradient = [ - Color(0xFF10B981), // Vert émeraude - Color(0xFF34D399), // Vert clair + Color(0xFF2E7D32), + Color(0xFF4CAF50), ]; // ═══════════════════════════════════════════════════════════════════════════ // MÉTHODES UTILITAIRES // ═══════════════════════════════════════════════════════════════════════════ - - /// Obtient une couleur avec opacité + static Color withOpacity(Color color, double opacity) { return color.withOpacity(opacity); } - - /// Obtient une couleur plus claire + static Color lighten(Color color, [double amount = 0.1]) { final hsl = HSLColor.fromColor(color); final lightness = (hsl.lightness + amount).clamp(0.0, 1.0); return hsl.withLightness(lightness).toColor(); } - - /// Obtient une couleur plus sombre + static Color darken(Color color, [double amount = 0.1]) { final hsl = HSLColor.fromColor(color); final lightness = (hsl.lightness - amount).clamp(0.0, 1.0); diff --git a/lib/shared/design_system/tokens/typography_tokens.dart b/lib/shared/design_system/tokens/typography_tokens.dart index 325c60f..d8c746e 100644 --- a/lib/shared/design_system/tokens/typography_tokens.dart +++ b/lib/shared/design_system/tokens/typography_tokens.dart @@ -5,6 +5,7 @@ library typography_tokens; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'color_tokens.dart'; /// Tokens typographiques - Système de texte moderne @@ -15,12 +16,12 @@ class TypographyTokens { // FAMILLES DE POLICES // ═══════════════════════════════════════════════════════════════════════════ - /// Police principale - Inter (moderne et lisible) - static const String primaryFontFamily = 'Inter'; - - /// Police secondaire - SF Pro Display (élégante) - static const String secondaryFontFamily = 'SF Pro Display'; - + /// Police principale - Roboto (Google Fonts, cross-platform) + static const String primaryFontFamily = 'Roboto'; + + /// Police display - Roboto (Google Fonts, titres) + static const String displayFontFamily = 'Roboto'; + /// Police monospace - JetBrains Mono (code et données) static const String monospaceFontFamily = 'JetBrains Mono'; @@ -28,33 +29,30 @@ class TypographyTokens { // ÉCHELLE TYPOGRAPHIQUE - Basée sur Material Design 3 // ═══════════════════════════════════════════════════════════════════════════ - /// Display - Titres principaux et héros - static const TextStyle displayLarge = TextStyle( - fontFamily: primaryFontFamily, - fontSize: 57.0, - fontWeight: FontWeight.w400, - letterSpacing: -0.25, - height: 1.12, - color: ColorTokens.onSurface, - ); - - static const TextStyle displayMedium = TextStyle( - fontFamily: primaryFontFamily, - fontSize: 45.0, - fontWeight: FontWeight.w400, - letterSpacing: 0.0, - height: 1.16, - color: ColorTokens.onSurface, - ); - - static const TextStyle displaySmall = TextStyle( - fontFamily: primaryFontFamily, - fontSize: 36.0, - fontWeight: FontWeight.w400, - letterSpacing: 0.0, - height: 1.22, - color: ColorTokens.onSurface, - ); + /// Display — Titres principaux (Roboto via GoogleFonts) + static TextStyle get displayLarge => GoogleFonts.roboto( + fontSize: 32.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + color: ColorTokens.onSurface, + ); + + static TextStyle get displayMedium => GoogleFonts.roboto( + fontSize: 28.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.25, + height: 1.25, + color: ColorTokens.onSurface, + ); + + static TextStyle get displaySmall => GoogleFonts.roboto( + fontSize: 24.0, + fontWeight: FontWeight.w600, + letterSpacing: 0.0, + height: 1.3, + color: ColorTokens.onSurface, + ); /// Headline - Titres de sections static const TextStyle headlineLarge = TextStyle( diff --git a/lib/shared/design_system/unionflow_design_system.dart b/lib/shared/design_system/unionflow_design_system.dart index e956e5c..0b75b56 100644 --- a/lib/shared/design_system/unionflow_design_system.dart +++ b/lib/shared/design_system/unionflow_design_system.dart @@ -1,9 +1,5 @@ library unionflow_design_system; -// ═══════════════════════════════════════════════════════════════════════════ -// IMPORTS de base pour le Design System -// ═══════════════════════════════════════════════════════════════════════════ - import 'package:flutter/material.dart'; import 'tokens/app_colors.dart'; import 'tokens/app_typography.dart'; @@ -20,54 +16,83 @@ export 'theme/app_theme.dart'; export 'components/components.dart'; // ═══════════════════════════════════════════════════════════════════════════ -// COMPATIBILITÉ - Shims pour les anciens tokens (Migration progressive) +// SHIMS DE COMPATIBILITÉ — Migration progressive vers design system unifié // ═══════════════════════════════════════════════════════════════════════════ -/// Shim de compatibilité pour ColorTokens +/// Shim ColorTokens — palette Vert Forêt/Ardoise class ColorTokens { - static const Color primary = AppColors.primaryGreen; - static const Color primaryContainer = AppColors.lightSurface; + // Primaires + static const Color primary = AppColors.primaryGreen; // #2E7D32 + static const Color primaryLight = AppColors.brandGreenLight; // #4CAF50 + static const Color primaryDark = AppColors.brandGreen; // #1B5E20 + static const Color primaryContainer = Color(0xFFE8F5E9); static const Color onPrimary = Colors.white; static const Color onPrimaryContainer = AppColors.textPrimaryLight; - static const Color secondary = AppColors.brandGreen; - static const Color secondaryContainer = AppColors.lightSurface; + + // Secondaires + static const Color secondary = AppColors.brandGreenLight; + static const Color secondaryContainer = Color(0xFFC8E6C9); static const Color onSecondary = Colors.white; - static const Color tertiary = AppColors.brandGreenLight; - static const Color tertiaryContainer = AppColors.lightSurface; + static const Color onSecondaryContainer = AppColors.textPrimaryLight; + + // Tertiaires + static const Color tertiary = AppColors.brandMint; + static const Color tertiaryContainer = Color(0xFFDCEDC8); static const Color onTertiary = Colors.white; + + // Surfaces static const Color surface = AppColors.lightSurface; - static const Color surfaceVariant = AppColors.lightSurface; + static const Color surfaceVariant = AppColors.lightBackground; + static const Color surfaceContainer = AppColors.lightSurface; static const Color background = AppColors.lightBackground; static const Color onSurface = AppColors.textPrimaryLight; static const Color onSurfaceVariant = AppColors.textSecondaryLight; static const Color outline = AppColors.lightBorder; - static const Color outlineVariant = AppColors.lightBorder; + static const Color outlineVariant = Color(0xFFDCEDC8); + + // Erreur / succès static const Color error = AppColors.error; static const Color onError = Colors.white; + static const Color errorContainer = Color(0xFFFEF2F2); static const Color success = AppColors.success; static const Color onSuccess = Colors.white; - static const Color info = Color(0xFF2196F3); - static const Color warning = Color(0xFFFFC107); - static const Color shadow = Color(0x1A000000); - + static const Color warning = AppColors.warning; + static const Color info = AppColors.info; + + // Navigation + static const Color navigationBackground = AppColors.lightSurface; + static const Color navigationSelected = AppColors.primaryGreen; + static const Color navigationUnselected = AppColors.textSecondaryLight; + static const Color navigationIndicator = Color(0xFFE8F5E9); + + // Ombres + static const Color shadow = Color(0x1A1C2B1C); + static const Color shadowMedium = Color(0x331C2B1C); + + // Verre / glassmorphism + static const Color glassBackground = Color(0x80FFFFFF); + static const Color glassBorder = Color(0x33FFFFFF); + + // Gradients static const List primaryGradient = [ + AppColors.brandGreen, AppColors.primaryGreen, - AppColors.brandGreenLight, + Color(0xFF388E3C), ]; } -/// Shim de compatibilité pour ShadowTokens +/// Shim ShadowTokens class ShadowTokens { static const List sm = [ BoxShadow( - color: Color(0x1A000000), + color: Color(0x1A1C2B1C), blurRadius: 4, offset: Offset(0, 2), ), ]; static const List md = [ BoxShadow( - color: Color(0x26000000), + color: Color(0x261C2B1C), blurRadius: 8, offset: Offset(0, 4), ), @@ -75,33 +100,58 @@ class ShadowTokens { static const List primary = md; } -/// Shim de compatibilité pour RadiusTokens +/// Shim RadiusTokens class RadiusTokens { static const double sm = SpacingTokens.radiusSm; static const double md = SpacingTokens.radiusMd; static const double lg = SpacingTokens.radiusLg; static const double xl = SpacingTokens.radiusXl; static const double circular = SpacingTokens.radiusCircular; - static const double round = SpacingTokens.radiusCircular; // Ajouté pour compatibilité + static const double round = SpacingTokens.radiusCircular; } -/// Shim de compatibilité pour TypographyTokens +/// Shim TypographyTokens class TypographyTokens { - static const TextStyle displayLarge = AppTypography.headerSmall; - static const TextStyle displayMedium = AppTypography.headerSmall; - static const TextStyle displaySmall = AppTypography.headerSmall; - static const TextStyle headlineLarge = AppTypography.headerSmall; + // Display (Playfair Display via AppTypography getters — non-const) + static TextStyle get displayLarge => AppTypography.displayLarge; + static TextStyle get displayMedium => AppTypography.displayMedium; + static TextStyle get displaySmall => AppTypography.displaySmall; + + // Headlines + static const TextStyle headlineLarge = AppTypography.headerLarge; static const TextStyle headlineMedium = AppTypography.headerSmall; - static const TextStyle headlineSmall = AppTypography.headerSmall; + static const TextStyle headlineSmall = AppTypography.titleMedium; + + // Titles static const TextStyle titleLarge = AppTypography.headerSmall; - static const TextStyle titleMedium = AppTypography.headerSmall; - static const TextStyle titleSmall = AppTypography.headerSmall; - static const TextStyle bodyLarge = AppTypography.bodyTextSmall; - static const TextStyle bodyMedium = AppTypography.bodyTextSmall; - static const TextStyle bodySmall = AppTypography.subtitleSmall; + static const TextStyle titleMedium = AppTypography.titleMedium; + static const TextStyle titleSmall = AppTypography.titleSmall; + + // Body + static const TextStyle bodyLarge = AppTypography.bodyLarge; + static const TextStyle bodyMedium = AppTypography.bodyMedium; + static const TextStyle bodySmall = AppTypography.bodyTextSmall; + + // Labels static const TextStyle labelLarge = AppTypography.actionText; - static const TextStyle labelMedium = AppTypography.badgeText; + static const TextStyle labelMedium = AppTypography.labelMedium; static const TextStyle labelSmall = AppTypography.badgeText; - static const TextStyle buttonLarge = AppTypography.actionText; - static const TextStyle cardValue = AppTypography.headerSmall; + + // Buttons + static const TextStyle buttonLarge = AppTypography.buttonLabel; + static const TextStyle buttonMedium = AppTypography.actionText; + + // Cards + static const TextStyle cardTitle = AppTypography.headerSmall; + static const TextStyle cardSubtitle = AppTypography.bodyTextSmall; + static const TextStyle cardValue = AppTypography.headerLarge; + + // Inputs + static const TextStyle inputLabel = AppTypography.labelMedium; + static const TextStyle inputText = AppTypography.bodyLarge; + static const TextStyle inputHint = AppTypography.bodyTextSmall; + + // Navigation + static const TextStyle navigationLabel = AppTypography.navLabel; + static const TextStyle navigationLabelSelected = AppTypography.navLabelSelected; } diff --git a/lib/shared/design_system/unionflow_design_v2.dart b/lib/shared/design_system/unionflow_design_v2.dart index c3c6117..dcc1b66 100644 --- a/lib/shared/design_system/unionflow_design_v2.dart +++ b/lib/shared/design_system/unionflow_design_v2.dart @@ -25,4 +25,8 @@ export 'components/union_unified_account_card.dart'; export 'components/union_period_filter.dart'; export 'components/union_export_button.dart'; export 'components/union_notification_badge.dart'; +export 'components/user_identity_card.dart'; +export 'components/uf_section_header.dart'; +export 'components/dashboard_activity_row.dart'; +export 'components/dashboard_event_row.dart'; diff --git a/lib/shared/widgets/core_card.dart b/lib/shared/widgets/core_card.dart index 7ae6b14..a19d06b 100644 --- a/lib/shared/widgets/core_card.dart +++ b/lib/shared/widgets/core_card.dart @@ -14,8 +14,8 @@ class CoreCard extends StatelessWidget { const CoreCard({ Key? key, required this.child, - this.padding = const EdgeInsets.all(12.0), - this.margin = const EdgeInsets.only(bottom: 10.0), + this.padding = const EdgeInsets.all(8.0), + this.margin = const EdgeInsets.only(bottom: 6.0), this.onTap, this.backgroundColor, }) : super(key: key); @@ -23,22 +23,22 @@ class CoreCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - + return Container( width: double.infinity, margin: margin, decoration: BoxDecoration( - color: backgroundColor ?? (isDark ? const Color(0xFF1A1A1A) : Colors.white), - borderRadius: BorderRadius.circular(6.0), + color: backgroundColor ?? (isDark ? AppColors.darkSurface : Colors.white), + borderRadius: BorderRadius.circular(10.0), border: Border.all( color: isDark ? AppColors.darkBorder.withOpacity(0.5) : AppColors.lightBorder, - width: 0.4, + width: 0.8, ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(isDark ? 0.3 : 0.03), - blurRadius: 10, - offset: const Offset(0, 4), + color: Colors.black.withOpacity(isDark ? 0.15 : 0.04), + blurRadius: 6, + offset: const Offset(0, 2), ), ], ), @@ -46,7 +46,7 @@ class CoreCard extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(6.0), + borderRadius: BorderRadius.circular(10.0), child: Padding( padding: padding, child: child, diff --git a/lib/shared/widgets/core_text_field.dart b/lib/shared/widgets/core_text_field.dart index 562d738..f574578 100644 --- a/lib/shared/widgets/core_text_field.dart +++ b/lib/shared/widgets/core_text_field.dart @@ -44,7 +44,7 @@ class CoreTextField extends StatelessWidget { : null, filled: true, fillColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide( @@ -63,7 +63,7 @@ class CoreTextField extends StatelessWidget { borderRadius: BorderRadius.circular(8), borderSide: const BorderSide( color: AppColors.primaryGreen, - width: 1.5, + width: 2, ), ), errorText: errorText, diff --git a/lib/shared/widgets/error_display_widget.dart b/lib/shared/widgets/error_display_widget.dart index 4b5e532..4496e25 100644 --- a/lib/shared/widgets/error_display_widget.dart +++ b/lib/shared/widgets/error_display_widget.dart @@ -21,17 +21,17 @@ class ErrorDisplayWidget extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Error icon Icon( _getErrorIcon(), - size: 64, + size: 40, color: _getErrorColor(context), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Error title Text( @@ -55,7 +55,7 @@ class ErrorDisplayWidget extends StatelessWidget { // Retry button (if retryable and callback provided) if (showRetryButton && failure.isRetryable && onRetry != null) ...[ - const SizedBox(height: 32), + const SizedBox(height: 16), ElevatedButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), @@ -63,7 +63,7 @@ class ErrorDisplayWidget extends StatelessWidget { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 32, - vertical: 16, + vertical: 10, ), ), ), @@ -151,7 +151,7 @@ class ErrorBanner extends StatelessWidget { Widget build(BuildContext context) { return Container( margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: _getErrorColor(context).withOpacity(0.1), border: Border.all( diff --git a/lib/shared/widgets/error_widget.dart b/lib/shared/widgets/error_widget.dart index 46c9ba0..e9a1aa4 100644 --- a/lib/shared/widgets/error_widget.dart +++ b/lib/shared/widgets/error_widget.dart @@ -29,17 +29,17 @@ class AppErrorWidget extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( icon ?? Icons.error_outline, - size: 64, + size: 40, color: Theme.of(context).colorScheme.error, ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( title ?? 'Oups !', style: Theme.of(context).textTheme.headlineSmall?.copyWith( @@ -56,7 +56,7 @@ class AppErrorWidget extends StatelessWidget { ), ), if (onRetry != null) ...[ - const SizedBox(height: 24), + const SizedBox(height: 12), ElevatedButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), @@ -64,7 +64,7 @@ class AppErrorWidget extends StatelessWidget { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 24, - vertical: 12, + vertical: 10, ), ), ), @@ -134,17 +134,17 @@ class EmptyDataWidget extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( icon ?? Icons.inbox_outlined, - size: 64, + size: 40, color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const SizedBox(height: 16), + const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, @@ -153,7 +153,7 @@ class EmptyDataWidget extends StatelessWidget { ), ), if (onAction != null && actionLabel != null) ...[ - const SizedBox(height: 24), + const SizedBox(height: 12), ElevatedButton( onPressed: onAction, child: Text(actionLabel!), diff --git a/lib/shared/widgets/loading_widget.dart b/lib/shared/widgets/loading_widget.dart index f1904fd..6a8b777 100644 --- a/lib/shared/widgets/loading_widget.dart +++ b/lib/shared/widgets/loading_widget.dart @@ -33,7 +33,7 @@ class AppLoadingWidget extends StatelessWidget { ), ), if (message != null) ...[ - const SizedBox(height: 16), + const SizedBox(height: 8), Text( message!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -62,7 +62,7 @@ class ShimmerListLoading extends StatelessWidget { Widget build(BuildContext context) { return ListView.builder( itemCount: itemCount, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -127,7 +127,7 @@ class ShimmerGridLoading extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.builder( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, crossAxisSpacing: 12, @@ -161,7 +161,7 @@ class ShimmerDetailLoading extends StatelessWidget { baseColor: Colors.grey[300]!, highlightColor: Colors.grey[100]!, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -173,7 +173,7 @@ class ShimmerDetailLoading extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), // Title Container( height: 24, @@ -187,7 +187,7 @@ class ShimmerDetailLoading extends StatelessWidget { width: 200, color: Colors.white, ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Content lines ...List.generate(5, (index) { return Padding( diff --git a/pubspec.lock b/pubspec.lock index b15bcf9..137b99e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -628,6 +628,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.1.2" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + url: "https://pub.dev" + source: hosted + version: "6.3.0" graphs: dependency: transitive description: @@ -833,6 +841,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "8bba79f4f0f7bc812fce2ca20915d15618c37721246ba6c3ef2aa7a763a90cf2" + url: "https://pub.dev" + source: hosted + version: "1.0.47" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" + url: "https://pub.dev" + source: hosted + version: "1.4.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7d1097f..2623443 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,8 @@ dependencies: flutter_svg: ^2.0.10+1 shimmer: ^3.0.0 pull_to_refresh: ^2.0.0 + google_fonts: ^6.2.1 + local_auth: ^2.3.0 # Utils uuid: ^4.5.1 diff --git a/test/features/dashboard/domain/usecases/get_compte_adherent_test.mocks.dart b/test/features/dashboard/domain/usecases/get_compte_adherent_test.mocks.dart index 5b88475..f9c02ee 100644 --- a/test/features/dashboard/domain/usecases/get_compte_adherent_test.mocks.dart +++ b/test/features/dashboard/domain/usecases/get_compte_adherent_test.mocks.dart @@ -68,8 +68,9 @@ class MockDashboardRepository extends _i1.Mock @override _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>> getDashboardData( String? organizationId, - String? userId, - ) => + String? userId, { + bool? useGlobalDashboard = false, + }) => (super.noSuchMethod( Invocation.method( #getDashboardData, @@ -77,6 +78,7 @@ class MockDashboardRepository extends _i1.Mock organizationId, userId, ], + {#useGlobalDashboard: useGlobalDashboard}, ), returnValue: _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>>.value( @@ -88,6 +90,7 @@ class MockDashboardRepository extends _i1.Mock organizationId, userId, ], + {#useGlobalDashboard: useGlobalDashboard}, ), )), ) as _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>>); diff --git a/test/features/dashboard/domain/usecases/get_dashboard_data_test.mocks.dart b/test/features/dashboard/domain/usecases/get_dashboard_data_test.mocks.dart index ea0a2a2..76fd07c 100644 --- a/test/features/dashboard/domain/usecases/get_dashboard_data_test.mocks.dart +++ b/test/features/dashboard/domain/usecases/get_dashboard_data_test.mocks.dart @@ -68,8 +68,9 @@ class MockDashboardRepository extends _i1.Mock @override _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>> getDashboardData( String? organizationId, - String? userId, - ) => + String? userId, { + bool? useGlobalDashboard = false, + }) => (super.noSuchMethod( Invocation.method( #getDashboardData, @@ -77,6 +78,7 @@ class MockDashboardRepository extends _i1.Mock organizationId, userId, ], + {#useGlobalDashboard: useGlobalDashboard}, ), returnValue: _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>>.value( @@ -88,6 +90,7 @@ class MockDashboardRepository extends _i1.Mock organizationId, userId, ], + {#useGlobalDashboard: useGlobalDashboard}, ), )), ) as _i4.Future<_i2.Either<_i5.Failure, _i7.DashboardEntity>>); diff --git a/test/features/events/domain/usecases/cancel_registration_test.mocks.dart b/test/features/events/domain/usecases/cancel_registration_test.mocks.dart index 17f7653..1d6e1f9 100644 --- a/test/features/events/domain/usecases/cancel_registration_test.mocks.dart +++ b/test/features/events/domain/usecases/cancel_registration_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/create_event_test.mocks.dart b/test/features/events/domain/usecases/create_event_test.mocks.dart index 09c294d..c43ca7b 100644 --- a/test/features/events/domain/usecases/create_event_test.mocks.dart +++ b/test/features/events/domain/usecases/create_event_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/delete_event_test.mocks.dart b/test/features/events/domain/usecases/delete_event_test.mocks.dart index 300350a..71a201d 100644 --- a/test/features/events/domain/usecases/delete_event_test.mocks.dart +++ b/test/features/events/domain/usecases/delete_event_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/get_event_by_id_test.mocks.dart b/test/features/events/domain/usecases/get_event_by_id_test.mocks.dart index 562d228..abaa418 100644 --- a/test/features/events/domain/usecases/get_event_by_id_test.mocks.dart +++ b/test/features/events/domain/usecases/get_event_by_id_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/get_event_participants_test.mocks.dart b/test/features/events/domain/usecases/get_event_participants_test.mocks.dart index 42974d7..ef2ee45 100644 --- a/test/features/events/domain/usecases/get_event_participants_test.mocks.dart +++ b/test/features/events/domain/usecases/get_event_participants_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/get_events_test.mocks.dart b/test/features/events/domain/usecases/get_events_test.mocks.dart index 5eb2cb6..cf5a29c 100644 --- a/test/features/events/domain/usecases/get_events_test.mocks.dart +++ b/test/features/events/domain/usecases/get_events_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/get_my_registrations_test.mocks.dart b/test/features/events/domain/usecases/get_my_registrations_test.mocks.dart index e567f32..79b3655 100644 --- a/test/features/events/domain/usecases/get_my_registrations_test.mocks.dart +++ b/test/features/events/domain/usecases/get_my_registrations_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/register_for_event_test.mocks.dart b/test/features/events/domain/usecases/register_for_event_test.mocks.dart index ed2e9d8..ed7ade7 100644 --- a/test/features/events/domain/usecases/register_for_event_test.mocks.dart +++ b/test/features/events/domain/usecases/register_for_event_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/submit_event_feedback_test.mocks.dart b/test/features/events/domain/usecases/submit_event_feedback_test.mocks.dart index edcb125..8a287f8 100644 --- a/test/features/events/domain/usecases/submit_event_feedback_test.mocks.dart +++ b/test/features/events/domain/usecases/submit_event_feedback_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/events/domain/usecases/update_event_test.mocks.dart b/test/features/events/domain/usecases/update_event_test.mocks.dart index 40a293f..918baa7 100644 --- a/test/features/events/domain/usecases/update_event_test.mocks.dart +++ b/test/features/events/domain/usecases/update_event_test.mocks.dart @@ -286,4 +286,35 @@ class MockIEvenementRepository extends _i1.Mock returnValue: _i5.Future>.value({}), ) as _i5.Future>); + + @override + _i5.Future submitFeedback({ + required String? evenementId, + required int? note, + String? commentaire, + }) => + (super.noSuchMethod( + Invocation.method( + #submitFeedback, + [], + { + #evenementId: evenementId, + #note: note, + #commentaire: commentaire, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getFeedbacks(String? evenementId) => + (super.noSuchMethod( + Invocation.method( + #getFeedbacks, + [evenementId], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); } diff --git a/test/features/members/bloc/membres_bloc_test.dart b/test/features/members/bloc/membres_bloc_test.dart new file mode 100644 index 0000000..ca6b502 --- /dev/null +++ b/test/features/members/bloc/membres_bloc_test.dart @@ -0,0 +1,330 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:unionflow_mobile_apps/features/members/bloc/membres_bloc.dart'; +import 'package:unionflow_mobile_apps/features/members/bloc/membres_event.dart'; +import 'package:unionflow_mobile_apps/features/members/bloc/membres_state.dart'; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_members.dart'; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_member_by_id.dart'; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/create_member.dart'; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/update_member.dart'; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/delete_member.dart'; +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/shared/models/membre_search_result.dart'; +import 'package:unionflow_mobile_apps/shared/models/membre_search_criteria.dart'; + +@GenerateMocks([ + GetMembers, + GetMemberById, + CreateMember, + UpdateMember, + DeleteMember, + SearchMembers, + GetMemberStats, + IMembreRepository, +]) +import 'membres_bloc_test.mocks.dart'; + +void main() { + late MembresBloc bloc; + late MockGetMembers mockGetMembers; + late MockGetMemberById mockGetMemberById; + late MockCreateMember mockCreateMember; + late MockUpdateMember mockUpdateMember; + late MockDeleteMember mockDeleteMember; + late MockSearchMembers mockSearchMembers; + late MockGetMemberStats mockGetMemberStats; + late MockIMembreRepository mockRepository; + + const orgId = 'org-123'; + + MembreSearchResult emptyResult() => MembreSearchResult( + membres: [], + totalElements: 0, + totalPages: 0, + currentPage: 0, + pageSize: 20, + numberOfElements: 0, + hasNext: false, + hasPrevious: false, + isFirst: true, + isLast: true, + criteria: MembreSearchCriteria(), + executionTimeMs: 0, + ); + + setUp(() { + mockGetMembers = MockGetMembers(); + mockGetMemberById = MockGetMemberById(); + mockCreateMember = MockCreateMember(); + mockUpdateMember = MockUpdateMember(); + mockDeleteMember = MockDeleteMember(); + mockSearchMembers = MockSearchMembers(); + mockGetMemberStats = MockGetMemberStats(); + mockRepository = MockIMembreRepository(); + + bloc = MembresBloc( + mockGetMembers, + mockGetMemberById, + mockCreateMember, + mockUpdateMember, + mockDeleteMember, + mockSearchMembers, + mockGetMemberStats, + mockRepository, + ); + }); + + tearDown(() => bloc.close()); + + // ───────────────────────────────────────────────────────────────────────── + // SuperAdmin — accès global sans filtre organisation + // ───────────────────────────────────────────────────────────────────────── + + group('LoadMembres — SuperAdmin (sans organisationId)', () { + blocTest( + 'appelle GetMembers sans filtre org et émet MembresLoaded sans organisationId', + build: () { + when(mockGetMembers( + page: anyNamed('page'), + size: anyNamed('size'), + recherche: anyNamed('recherche'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres()), + expect: () => [ + const MembresLoading(), + isA().having( + (s) => s.organisationId, + 'organisationId', + isNull, + ), + ], + verify: (_) { + // GetMembers doit être appelé avec les bons paramètres + final captured = verify(mockGetMembers( + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + recherche: captureAnyNamed('recherche'), + )).captured; + expect(captured[0], equals(0)); // page + expect(captured[1], equals(20)); // size + expect(captured[2], isNull); // recherche + + // SearchMembers ne doit jamais être appelé + verifyNever(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )); + }, + ); + + blocTest( + 'transmet le terme de recherche à GetMembers', + build: () { + when(mockGetMembers( + page: anyNamed('page'), + size: anyNamed('size'), + recherche: anyNamed('recherche'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres(recherche: 'Jean')), + expect: () => [ + const MembresLoading(), + isA(), + ], + verify: (_) { + final captured = verify(mockGetMembers( + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + recherche: captureAnyNamed('recherche'), + )).captured; + expect(captured[2], equals('Jean')); // recherche + + verifyNever(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )); + }, + ); + }); + + // ───────────────────────────────────────────────────────────────────────── + // OrgAdmin — accès limité à son organisation + // ───────────────────────────────────────────────────────────────────────── + + group('LoadMembres — OrgAdmin (avec organisationId)', () { + blocTest( + 'appelle SearchMembers avec organisationIds et émet MembresLoaded avec organisationId', + build: () { + when(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres(organisationId: orgId)), + expect: () => [ + const MembresLoading(), + isA().having( + (s) => s.organisationId, + 'organisationId', + equals(orgId), + ), + ], + verify: (_) { + // GetMembers ne doit JAMAIS être appelé pour un OrgAdmin + verifyNever(mockGetMembers( + page: anyNamed('page'), + size: anyNamed('size'), + recherche: anyNamed('recherche'), + )); + + // SearchMembers doit être appelé avec l'organisationId dans les critères + final captured = verify(mockSearchMembers( + criteria: captureAnyNamed('criteria'), + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + )).captured; + + final criteria = captured[0] as MembreSearchCriteria; + expect(criteria.organisationIds, equals([orgId])); + expect(criteria.query, isNull); + expect(captured[1], equals(0)); // page + expect(captured[2], equals(20)); // size + }, + ); + + blocTest( + 'OrgAdmin avec recherche : ajoute query aux critères', + build: () { + when(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres(organisationId: orgId, recherche: 'Dupont')), + expect: () => [ + const MembresLoading(), + isA(), + ], + verify: (_) { + final captured = verify(mockSearchMembers( + criteria: captureAnyNamed('criteria'), + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + )).captured; + + final criteria = captured[0] as MembreSearchCriteria; + expect(criteria.organisationIds, equals([orgId])); + expect(criteria.query, equals('Dupont')); + }, + ); + + blocTest( + 'OrgAdmin avec recherche vide : query non transmis', + build: () { + when(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres(organisationId: orgId, recherche: '')), + expect: () => [const MembresLoading(), isA()], + verify: (_) { + final captured = verify(mockSearchMembers( + criteria: captureAnyNamed('criteria'), + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + )).captured; + + final criteria = captured[0] as MembreSearchCriteria; + expect(criteria.organisationIds, equals([orgId])); + // recherche vide → query null (pas transmis aux critères) + expect(criteria.query, isNull); + }, + ); + + blocTest( + 'OrgAdmin : la pagination conserve l organisationId', + build: () { + when(mockSearchMembers( + criteria: anyNamed('criteria'), + page: anyNamed('page'), + size: anyNamed('size'), + )).thenAnswer((_) async => emptyResult()); + return bloc; + }, + act: (b) => b.add(const LoadMembres(organisationId: orgId, page: 1)), + expect: () => [ + const MembresLoading(), + isA().having( + (s) => s.organisationId, + 'organisationId', + equals(orgId), + ), + ], + verify: (_) { + final captured = verify(mockSearchMembers( + criteria: captureAnyNamed('criteria'), + page: captureAnyNamed('page'), + size: captureAnyNamed('size'), + )).captured; + expect(captured[1], equals(1)); // page = 1 + }, + ); + }); + + // ───────────────────────────────────────────────────────────────────────── + // MembresLoaded.copyWith + // ───────────────────────────────────────────────────────────────────────── + + group('MembresLoaded.copyWith', () { + test('preserve organisationId si non fourni', () { + const state = MembresLoaded( + membres: [], + totalElements: 5, + totalPages: 1, + organisationId: orgId, + ); + final copy = state.copyWith(totalElements: 10); + expect(copy.organisationId, equals(orgId)); + expect(copy.totalElements, equals(10)); + }); + + test('met à jour organisationId si fourni', () { + const state = MembresLoaded( + membres: [], + totalElements: 5, + totalPages: 1, + organisationId: orgId, + ); + final copy = state.copyWith(organisationId: 'new-org'); + expect(copy.organisationId, equals('new-org')); + }); + + test('organisationId null par défaut si non défini', () { + const state = MembresLoaded( + membres: [], + totalElements: 0, + totalPages: 0, + ); + expect(state.organisationId, isNull); + final copy = state.copyWith(totalElements: 1, totalPages: 1); + expect(copy.organisationId, isNull); + }); + }); +} diff --git a/test/features/members/bloc/membres_bloc_test.mocks.dart b/test/features/members/bloc/membres_bloc_test.mocks.dart new file mode 100644 index 0000000..0eb477a --- /dev/null +++ b/test/features/members/bloc/membres_bloc_test.mocks.dart @@ -0,0 +1,497 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in unionflow_mobile_apps/test/features/members/bloc/membres_bloc_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:unionflow_mobile_apps/features/members/data/models/membre_complete_model.dart' + as _i3; +import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart' + as _i13; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/create_member.dart' + as _i7; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/delete_member.dart' + as _i9; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_member_by_id.dart' + as _i6; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_member_stats.dart' + as _i12; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/get_members.dart' + as _i4; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/search_members.dart' + as _i10; +import 'package:unionflow_mobile_apps/features/members/domain/usecases/update_member.dart' + as _i8; +import 'package:unionflow_mobile_apps/shared/models/membre_search_criteria.dart' + as _i11; +import 'package:unionflow_mobile_apps/shared/models/membre_search_result.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeMembreSearchResult_0 extends _i1.SmartFake + implements _i2.MembreSearchResult { + _FakeMembreSearchResult_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeMembreCompletModel_1 extends _i1.SmartFake + implements _i3.MembreCompletModel { + _FakeMembreCompletModel_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [GetMembers]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGetMembers extends _i1.Mock implements _i4.GetMembers { + MockGetMembers() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i2.MembreSearchResult> call({ + int? page = 0, + int? size = 20, + String? recherche, + }) => + (super.noSuchMethod( + Invocation.method( + #call, + [], + { + #page: page, + #size: size, + #recherche: recherche, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #call, + [], + { + #page: page, + #size: size, + #recherche: recherche, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); +} + +/// A class which mocks [GetMemberById]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGetMemberById extends _i1.Mock implements _i6.GetMemberById { + MockGetMemberById() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i3.MembreCompletModel?> call(String? id) => (super.noSuchMethod( + Invocation.method( + #call, + [id], + ), + returnValue: _i5.Future<_i3.MembreCompletModel?>.value(), + ) as _i5.Future<_i3.MembreCompletModel?>); +} + +/// A class which mocks [CreateMember]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCreateMember extends _i1.Mock implements _i7.CreateMember { + MockCreateMember() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i3.MembreCompletModel> call(_i3.MembreCompletModel? membre) => + (super.noSuchMethod( + Invocation.method( + #call, + [membre], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #call, + [membre], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); +} + +/// A class which mocks [UpdateMember]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUpdateMember extends _i1.Mock implements _i8.UpdateMember { + MockUpdateMember() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i3.MembreCompletModel> call( + String? id, + _i3.MembreCompletModel? membre, + ) => + (super.noSuchMethod( + Invocation.method( + #call, + [ + id, + membre, + ], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #call, + [ + id, + membre, + ], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); +} + +/// A class which mocks [DeleteMember]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeleteMember extends _i1.Mock implements _i9.DeleteMember { + MockDeleteMember() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future call(String? id) => (super.noSuchMethod( + Invocation.method( + #call, + [id], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); +} + +/// A class which mocks [SearchMembers]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSearchMembers extends _i1.Mock implements _i10.SearchMembers { + MockSearchMembers() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i2.MembreSearchResult> call({ + required _i11.MembreSearchCriteria? criteria, + int? page = 0, + int? size = 20, + }) => + (super.noSuchMethod( + Invocation.method( + #call, + [], + { + #criteria: criteria, + #page: page, + #size: size, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #call, + [], + { + #criteria: criteria, + #page: page, + #size: size, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); +} + +/// A class which mocks [GetMemberStats]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGetMemberStats extends _i1.Mock implements _i12.GetMemberStats { + MockGetMemberStats() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future> call() => (super.noSuchMethod( + Invocation.method( + #call, + [], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); +} + +/// A class which mocks [IMembreRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIMembreRepository extends _i1.Mock implements _i13.IMembreRepository { + MockIMembreRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i2.MembreSearchResult> getMembres({ + int? page = 0, + int? size = 20, + String? recherche, + }) => + (super.noSuchMethod( + Invocation.method( + #getMembres, + [], + { + #page: page, + #size: size, + #recherche: recherche, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #getMembres, + [], + { + #page: page, + #size: size, + #recherche: recherche, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); + + @override + _i5.Future<_i3.MembreCompletModel?> getMembreById(String? id) => + (super.noSuchMethod( + Invocation.method( + #getMembreById, + [id], + ), + returnValue: _i5.Future<_i3.MembreCompletModel?>.value(), + ) as _i5.Future<_i3.MembreCompletModel?>); + + @override + _i5.Future<_i3.MembreCompletModel> createMembre( + _i3.MembreCompletModel? membre) => + (super.noSuchMethod( + Invocation.method( + #createMembre, + [membre], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #createMembre, + [membre], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); + + @override + _i5.Future<_i3.MembreCompletModel> updateMembre( + String? id, + _i3.MembreCompletModel? membre, + ) => + (super.noSuchMethod( + Invocation.method( + #updateMembre, + [ + id, + membre, + ], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #updateMembre, + [ + id, + membre, + ], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); + + @override + _i5.Future deleteMembre(String? id) => (super.noSuchMethod( + Invocation.method( + #deleteMembre, + [id], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i3.MembreCompletModel> activateMembre(String? id) => + (super.noSuchMethod( + Invocation.method( + #activateMembre, + [id], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #activateMembre, + [id], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); + + @override + _i5.Future<_i3.MembreCompletModel> deactivateMembre(String? id) => + (super.noSuchMethod( + Invocation.method( + #deactivateMembre, + [id], + ), + returnValue: + _i5.Future<_i3.MembreCompletModel>.value(_FakeMembreCompletModel_1( + this, + Invocation.method( + #deactivateMembre, + [id], + ), + )), + ) as _i5.Future<_i3.MembreCompletModel>); + + @override + _i5.Future<_i2.MembreSearchResult> searchMembres({ + required _i11.MembreSearchCriteria? criteria, + int? page = 0, + int? size = 20, + }) => + (super.noSuchMethod( + Invocation.method( + #searchMembres, + [], + { + #criteria: criteria, + #page: page, + #size: size, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #searchMembres, + [], + { + #criteria: criteria, + #page: page, + #size: size, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); + + @override + _i5.Future<_i2.MembreSearchResult> getActiveMembers({ + int? page = 0, + int? size = 20, + }) => + (super.noSuchMethod( + Invocation.method( + #getActiveMembers, + [], + { + #page: page, + #size: size, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #getActiveMembers, + [], + { + #page: page, + #size: size, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); + + @override + _i5.Future<_i2.MembreSearchResult> getBureauMembers({ + int? page = 0, + int? size = 20, + }) => + (super.noSuchMethod( + Invocation.method( + #getBureauMembers, + [], + { + #page: page, + #size: size, + }, + ), + returnValue: + _i5.Future<_i2.MembreSearchResult>.value(_FakeMembreSearchResult_0( + this, + Invocation.method( + #getBureauMembers, + [], + { + #page: page, + #size: size, + }, + ), + )), + ) as _i5.Future<_i2.MembreSearchResult>); + + @override + _i5.Future> getMembresStats() => (super.noSuchMethod( + Invocation.method( + #getMembresStats, + [], + ), + returnValue: + _i5.Future>.value({}), + ) as _i5.Future>); +} diff --git a/test/features/organizations/domain/usecases/create_organization_test.mocks.dart b/test/features/organizations/domain/usecases/create_organization_test.mocks.dart index 450a38f..5ef061c 100644 --- a/test/features/organizations/domain/usecases/create_organization_test.mocks.dart +++ b/test/features/organizations/domain/usecases/create_organization_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/delete_organization_test.mocks.dart b/test/features/organizations/domain/usecases/delete_organization_test.mocks.dart index fc25d08..536e9bb 100644 --- a/test/features/organizations/domain/usecases/delete_organization_test.mocks.dart +++ b/test/features/organizations/domain/usecases/delete_organization_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/get_organization_by_id_test.mocks.dart b/test/features/organizations/domain/usecases/get_organization_by_id_test.mocks.dart index bf36e64..b21a2c8 100644 --- a/test/features/organizations/domain/usecases/get_organization_by_id_test.mocks.dart +++ b/test/features/organizations/domain/usecases/get_organization_by_id_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/get_organization_members_test.mocks.dart b/test/features/organizations/domain/usecases/get_organization_members_test.mocks.dart index 352fe61..f7a52d1 100644 --- a/test/features/organizations/domain/usecases/get_organization_members_test.mocks.dart +++ b/test/features/organizations/domain/usecases/get_organization_members_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/get_organizations_test.mocks.dart b/test/features/organizations/domain/usecases/get_organizations_test.mocks.dart index 5a4cd11..a1ab927 100644 --- a/test/features/organizations/domain/usecases/get_organizations_test.mocks.dart +++ b/test/features/organizations/domain/usecases/get_organizations_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/update_organization_config_test.mocks.dart b/test/features/organizations/domain/usecases/update_organization_config_test.mocks.dart index 2da4536..d6537a2 100644 --- a/test/features/organizations/domain/usecases/update_organization_config_test.mocks.dart +++ b/test/features/organizations/domain/usecases/update_organization_config_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region, diff --git a/test/features/organizations/domain/usecases/update_organization_test.mocks.dart b/test/features/organizations/domain/usecases/update_organization_test.mocks.dart index d32c2da..f296e6c 100644 --- a/test/features/organizations/domain/usecases/update_organization_test.mocks.dart +++ b/test/features/organizations/domain/usecases/update_organization_test.mocks.dart @@ -176,7 +176,7 @@ class MockIOrganizationRepository extends _i1.Mock @override _i4.Future> searchOrganizations({ String? nom, - _i2.TypeOrganization? type, + String? type, _i2.StatutOrganization? statut, String? ville, String? region,