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 '../../data/models/organization_model.dart'; 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/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'; import '../../bloc/org_switcher_bloc.dart'; /// Page de gestion des organisations - Interface sophistiquée et exhaustive class OrganizationsPage extends StatefulWidget { const OrganizationsPage({super.key}); @override State createState() => _OrganizationsPageState(); } class _OrganizationsPageState extends State with TickerProviderStateMixin { final TextEditingController _searchController = TextEditingController(); TabController? _tabController; final ScrollController _scrollController = ScrollController(); List _availableTypes = []; /// Cache de la dernière liste connue. /// Évite de perdre l'affichage quand le bloc passe dans un état non-liste /// (OrganizationLoaded, OrganizationCreated, etc.) après navigation vers /// la page de détail ou une action CRUD. OrganizationsLoaded? _cachedListState; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _tabController?.dispose(); _searchController.dispose(); _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent * 0.9) { context.read().add(const LoadMoreOrganizations()); } } /// Titre dynamique de l'AppBar selon le rôle et le nombre d'organisations. /// /// SuperAdmin → "Gestion des Organisations" /// OrgAdmin 1 org → "Mon Organisation" /// OrgAdmin 2+ orgs → "Mes Organisations" String _appBarTitle(BuildContext context) { final authState = context.read().state; if (authState is! AuthAuthenticated) return 'Organisations'; if (authState.effectiveRole == UserRole.superAdmin) { return 'Gestion des Organisations'; } try { final switcherState = context.read().state; if (switcherState is OrgSwitcherLoaded) { return switcherState.organisations.length > 1 ? 'Mes Organisations' : 'Mon Organisation'; } } catch (_) {} return 'Mon Organisation'; } List _calculateAvailableTypes(List organizations) { if (organizations.isEmpty) return [null]; final types = organizations.map((org) => org.typeOrganisation).toSet().toList()..sort((a, b) => a.compareTo(b)); return [null, ...types]; } void _updateTabController(List newTypes) { if (_availableTypes.length != newTypes.length || !_availableTypes.every((t) => newTypes.contains(t))) { _availableTypes = newTypes; _tabController?.dispose(); _tabController = TabController(length: _availableTypes.length, vsync: this); } } @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, state) { if (state is OrganizationsError) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(state.message), backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 4), action: SnackBarAction( label: 'Réessayer', textColor: AppColors.onError, onPressed: () => context.read().add(const LoadOrganizations(refresh: true)), ), )); } else if (state is OrganizationCreated) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('Organisation créée avec succès'), backgroundColor: AppColors.success, duration: Duration(seconds: 2), )); } else if (state is OrganizationUpdated) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('Organisation mise à jour avec succès'), backgroundColor: AppColors.success, duration: Duration(seconds: 2), )); } else if (state is OrganizationDeleted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('Organisation supprimée avec succès'), backgroundColor: AppColors.success, duration: Duration(seconds: 2), )); } }, builder: (context, state) { // Détermine la liste d'organisations courante (avec fallback cache // pour ne pas perdre les onglets entre navigations) OrganizationsLoaded? loadedState; if (state is OrganizationsLoaded) { loadedState = state; } else if (_cachedListState != null) { loadedState = _cachedListState; } final availableTypes = loadedState != null ? _calculateAvailableTypes(loadedState.organizations) : []; _updateTabController(availableTypes); return AfricanPatternBackground( child: Scaffold( backgroundColor: Colors.transparent, appBar: UFAppBar( title: _appBarTitle(context), moduleGradient: ModuleColors.organisationsGradient, 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: () => context.read().add(const RefreshOrganizations()), tooltip: 'Rafraîchir', ), ], bottom: (_tabController != null && availableTypes.isNotEmpty) ? TabBar( controller: _tabController!, isScrollable: true, labelColor: Colors.white, unselectedLabelColor: Colors.white70, indicatorColor: Colors.white, indicatorSize: TabBarIndicatorSize.label, labelStyle: AppTypography.actionText .copyWith(fontSize: 10, fontWeight: FontWeight.bold), onTap: (i) { final selectedType = availableTypes[i]; if (selectedType != null) { context .read() .add(FilterOrganizationsByType(selectedType)); } else { context .read() .add(const ClearOrganizationsFilters()); } }, tabs: availableTypes .map((type) => Tab( child: Text((type ?? 'Toutes').toUpperCase()))) .toList(), ) : null, ), body: SafeArea(child: _buildBody(context, state)), floatingActionButton: _buildActionButton(context, state), ), ); }, ); } Widget _buildBody(BuildContext context, OrganizationsState state) { // Mémoriser le dernier état liste dès qu'on l'obtient if (state is OrganizationsLoaded) _cachedListState = state; if (state is OrganizationsInitial || state is OrganizationsLoading) { // Données déjà en cache (ex. retour depuis détail) → afficher sans loader if (_cachedListState != null) return _buildLoadedBody(context, _cachedListState!); return _buildLoadingState(context); } if (state is OrganizationsLoaded) { return _buildLoadedBody(context, state); } if (state is OrganizationsLoadingMore) { return SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(SpacingTokens.md), physics: const AlwaysScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLoadingMorePlaceholder(context, state.currentOrganizations), Padding( padding: const EdgeInsets.all(SpacingTokens.md), child: Center(child: CircularProgressIndicator(color: ModuleColors.organisations)), ), const SizedBox(height: 80), ], ), ); } if (state is OrganizationsError) return _buildErrorState(context, state); // État non-liste (OrganizationLoaded, OrganizationCreated, etc.) : // réutiliser la liste en cache plutôt que d'afficher un loader indéfini. if (_cachedListState != null) return _buildLoadedBody(context, _cachedListState!); return _buildLoadingState(context); } /// Contenu principal de la liste — extrait pour être réutilisable via le cache. /// Note : les onglets de catégories (TabBar) sont désormais dans /// UFAppBar.bottom (pattern Adhésions). _updateTabController est appelé /// au niveau supérieur dans le builder du BlocConsumer. Widget _buildLoadedBody(BuildContext context, OrganizationsLoaded state) { return RefreshIndicator( color: ModuleColors.organisations, onRefresh: () async => context.read().add(const RefreshOrganizations()), child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(SpacingTokens.md), physics: const AlwaysScrollableScrollPhysics(), child: AnimatedFadeIn( duration: const Duration(milliseconds: 400), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedSlideIn( duration: const Duration(milliseconds: 500), curve: Curves.easeOut, child: _buildKpiHeader(context, state), ), const SizedBox(height: SpacingTokens.md), AnimatedSlideIn( duration: const Duration(milliseconds: 600), curve: Curves.easeOut, child: _buildSearchBar(context, state), ), const SizedBox(height: SpacingTokens.md), AnimatedSlideIn( duration: const Duration(milliseconds: 800), curve: Curves.easeOut, child: _buildOrganizationsList(context, state), ), const SizedBox(height: 80), ], ), ), ), ); } // ─── KPI Header ─────────────────────────────────────────────────────────── Widget _buildKpiHeader(BuildContext context, OrganizationsLoaded state) { final orgs = state.organizations; final total = orgs.length; final active = orgs.where((o) => o.statut == StatutOrganization.active).length; final suspended = orgs.where((o) => o.statut == StatutOrganization.suspendue).length; final totalMembers = orgs.fold(0, (sum, o) => sum + o.nombreMembres); final activePercent = total > 0 ? active / total : 0.0; return Container( padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(RadiusTokens.lg), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant, width: 1), boxShadow: const [BoxShadow(color: AppColors.shadow, blurRadius: 8, offset: Offset(0, 2))], ), child: Row( children: [ _kpiTile(context, 'Total', total.toString(), ModuleColors.organisations, Icons.business_outlined), _kpiSep(context), _kpiTileProgress(context, 'Actives', '${(activePercent * 100).toStringAsFixed(0)}%', AppColors.success, activePercent), _kpiSep(context), _kpiTile(context, 'Membres', totalMembers.toString(), ModuleColors.organisations, Icons.people_outline), _kpiSep(context), _kpiTile(context, 'Suspendues', suspended.toString(), AppColors.warning, Icons.pause_circle_outline), ], ), ); } Widget _kpiTile(BuildContext context, String label, String value, Color color, IconData icon) { return Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(height: 1), Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color)), Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color.withOpacity(0.75)), textAlign: TextAlign.center), ], ), ); } Widget _kpiTileProgress(BuildContext context, String label, String value, Color color, double progress) { return Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color)), const SizedBox(height: 3), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: progress, backgroundColor: color.withOpacity(0.15), valueColor: AlwaysStoppedAnimation(color), minHeight: 4, ), ), const SizedBox(height: 2), Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color.withOpacity(0.75)), textAlign: TextAlign.center), ], ), ); } Widget _kpiSep(BuildContext context) => Container(width: 1, height: 42, color: Theme.of(context).colorScheme.outlineVariant); // ─── Barre de recherche ─────────────────────────────────────────────────── Widget _buildSearchBar(BuildContext context, OrganizationsLoaded state) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(RadiusTokens.lg), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant, width: 1), boxShadow: const [BoxShadow(color: AppColors.shadow, blurRadius: 8, offset: Offset(0, 2))], ), child: TextField( controller: _searchController, onChanged: (v) => context.read().add(SearchOrganizations(v)), style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( hintText: 'Rechercher par nom, type, localisation...', hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14), prefixIcon: const Icon(Icons.search, color: ModuleColors.organisations), suffixIcon: _searchController.text.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); context.read().add(const SearchOrganizations('')); }, icon: Icon(Icons.clear, color: Theme.of(context).colorScheme.onSurfaceVariant), ) : null, border: OutlineInputBorder(borderRadius: BorderRadius.circular(RadiusTokens.lg), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(RadiusTokens.lg), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(RadiusTokens.lg), borderSide: const BorderSide(color: ModuleColors.organisations, width: 1.5)), filled: true, fillColor: Theme.of(context).colorScheme.surface, contentPadding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm), ), ), ); } // ─── Liste des organisations ────────────────────────────────────────────── Widget _buildOrganizationsList(BuildContext context, OrganizationsLoaded state) { final organizations = state.filteredOrganizations; if (organizations.isEmpty) return _buildEmptyState(context); final authState = context.read().state; final canManageOrgs = authState is AuthAuthenticated && (authState.effectiveRole == UserRole.superAdmin || authState.effectiveRole == UserRole.orgAdmin); return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: organizations.length, separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm), itemBuilder: (context, i) { final org = organizations[i]; return AnimatedFadeIn( duration: Duration(milliseconds: 300 + (i * 50)), child: OrganizationCard( organization: org, onTap: () => _showOrganizationDetails(org), onEdit: canManageOrgs ? () => _showEditOrganizationDialog(org) : null, onDelete: canManageOrgs ? () => _confirmDeleteOrganization(org) : null, showActions: canManageOrgs, ), ); }, ); } Widget _buildLoadingMorePlaceholder(BuildContext context, List orgs) { return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: orgs.length, separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm), itemBuilder: (context, i) => OrganizationCard(organization: orgs[i], onTap: () => _showOrganizationDetails(orgs[i]), showActions: false), ); } // ─── FAB ────────────────────────────────────────────────────────────────── Widget? _buildActionButton(BuildContext context, OrganizationsState state) { // Afficher le FAB si on a des données (état direct ou cache) final hasData = state is OrganizationsLoaded || state is OrganizationsLoadingMore || _cachedListState != null; if (!hasData) return null; final authState = context.read().state; if (authState is! AuthAuthenticated || authState.effectiveRole != UserRole.superAdmin) return null; return FloatingActionButton( onPressed: _showCreateOrganizationDialog, backgroundColor: ModuleColors.organisations, foregroundColor: AppColors.onPrimary, elevation: 6, tooltip: 'Nouvelle organisation', child: const Icon(Icons.add), ); } // ─── Empty state ────────────────────────────────────────────────────────── Widget _buildEmptyState(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(SpacingTokens.xl), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.all(SpacingTokens.xl), decoration: BoxDecoration(color: ModuleColors.organisations.withOpacity(0.08), shape: BoxShape.circle), child: const Icon(Icons.business_outlined, size: 56, color: ModuleColors.organisations), ), const SizedBox(height: SpacingTokens.lg), Text( 'Aucune organisation trouvée', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: SpacingTokens.xs), Text( 'Modifiez vos critères de recherche\nou créez une nouvelle organisation.', style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.5), textAlign: TextAlign.center, ), const SizedBox(height: SpacingTokens.lg), FilledButton.icon( onPressed: () { context.read().add(const ClearOrganizationsFilters()); _searchController.clear(); }, icon: const Icon(Icons.filter_list_off, size: 18), label: const Text('Réinitialiser les filtres'), style: FilledButton.styleFrom(backgroundColor: ModuleColors.organisations), ), ], ), ), ); } // ─── Loading state ──────────────────────────────────────────────────────── Widget _buildLoadingState(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(color: ModuleColors.organisations, strokeWidth: 3), const SizedBox(height: SpacingTokens.md), Text( 'Chargement des organisations...', style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), ); } // ─── Error state ────────────────────────────────────────────────────────── Widget _buildErrorState(BuildContext context, OrganizationsError state) { final cs = Theme.of(context).colorScheme; return Center( child: Container( margin: const EdgeInsets.all(SpacingTokens.xl), padding: const EdgeInsets.all(SpacingTokens.xl), decoration: BoxDecoration( color: cs.errorContainer.withOpacity(0.3), borderRadius: BorderRadius.circular(RadiusTokens.lg), border: Border.all(color: cs.error.withOpacity(0.3), width: 1), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 56, color: cs.error), const SizedBox(height: SpacingTokens.md), Text( state.message, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface), textAlign: TextAlign.center, ), if (state.details != null) ...[ const SizedBox(height: SpacingTokens.xs), Text(state.details!, style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), textAlign: TextAlign.center), ], const SizedBox(height: SpacingTokens.lg), FilledButton.icon( onPressed: () => context.read().add(const LoadOrganizations(refresh: true)), icon: const Icon(Icons.refresh, size: 18), label: const Text('Réessayer'), style: FilledButton.styleFrom(backgroundColor: ModuleColors.organisations), ), ], ), ), ); } // ─── Actions ────────────────────────────────────────────────────────────── void _showOrganizationDetails(OrganizationModel org) { final orgId = org.id; if (orgId == null || orgId.isEmpty) return; final bloc = context.read(); Navigator.of(context).push(MaterialPageRoute( builder: (_) => BlocProvider.value(value: bloc, child: OrganizationDetailPage(organizationId: orgId)), )); } void _showCreateOrganizationDialog() { final bloc = context.read(); showDialog( context: context, builder: (_) => BlocProvider.value(value: bloc, child: const CreateOrganizationDialog()), ); } void _showEditOrganizationDialog(OrganizationModel org) { final bloc = context.read(); showDialog( context: context, builder: (_) => BlocProvider.value(value: bloc, child: EditOrganizationDialog(organization: org)), ); } void _confirmDeleteOrganization(OrganizationModel org) { final bloc = context.read(); showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Supprimer l\'organisation'), content: Text('Voulez-vous vraiment supprimer "${org.nom}" ?'), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Annuler')), FilledButton( onPressed: () { if (org.id != null) bloc.add(DeleteOrganization(org.id!)); Navigator.of(ctx).pop(); }, style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: AppColors.onError), child: const Text('Supprimer'), ), ], ), ); } }