Files
unionflow-mobile-apps/lib/features/organizations/presentation/pages/organizations_page.dart
dahoud b2f29922d3 feat(navigation): titre 'Mon/Mes Organisations' dynamique + fallback modules
Plus page (more_page.dart) :
- Titre entrée Organisations dynamique selon rôle/nombre d'orgs :
  * SuperAdmin → 'Gestion des Organisations'
  * OrgAdmin 1 org → 'Mon Organisation'
  * OrgAdmin 2+ orgs → 'Mes Organisations'
- Sous-titres adaptés
- Helpers _orgTitle/_orgSubtitle utilisent OrgSwitcherBloc pour le count
- 7 modules non encore implémentés (TONTINE, CREDIT, AGRICULTURE, COLLECTE_FONDS,
  PROJETS_ONG, CULTE_DONS, VOTES) : Navigator.pushNamed remplacé par _comingSoon()
  SnackBar 'Disponible prochainement' (les routes n'étaient enregistrées nulle part)
- Entrée 'Comptes épargne' → 'Épargne & Crédit' (terme métier pour mutuelles)

Organizations page :
- AppBar title dynamique via _appBarTitle() (même logique que more_page)
- SnackBars OrganizationCreated/Updated/Deleted : Color(0xFF16A34A) → AppColors.success
2026-04-15 20:14:42 +00:00

603 lines
26 KiB
Dart

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<OrganizationsPage> createState() => _OrganizationsPageState();
}
class _OrganizationsPageState extends State<OrganizationsPage> with TickerProviderStateMixin {
final TextEditingController _searchController = TextEditingController();
TabController? _tabController;
final ScrollController _scrollController = ScrollController();
List<String?> _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<OrganizationsBloc>().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<AuthBloc>().state;
if (authState is! AuthAuthenticated) return 'Organisations';
if (authState.effectiveRole == UserRole.superAdmin) {
return 'Gestion des Organisations';
}
try {
final switcherState = context.read<OrgSwitcherBloc>().state;
if (switcherState is OrgSwitcherLoaded) {
return switcherState.organisations.length > 1
? 'Mes Organisations'
: 'Mon Organisation';
}
} catch (_) {}
return 'Mon Organisation';
}
List<String?> _calculateAvailableTypes(List<OrganizationModel> 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<String?> 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<OrganizationsBloc, OrganizationsState>(
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<OrganizationsBloc>().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)
: <String?>[];
_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<void>(builder: (_) => const OrgTypesPage())),
tooltip: 'Types d\'organisations',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<OrganizationsBloc>().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<OrganizationsBloc>()
.add(FilterOrganizationsByType(selectedType));
} else {
context
.read<OrganizationsBloc>()
.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<OrganizationsBloc>().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<int>(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>(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<OrganizationsBloc>().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<OrganizationsBloc>().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<AuthBloc>().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<OrganizationModel> 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<AuthBloc>().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<OrganizationsBloc>().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<OrganizationsBloc>().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<OrganizationsBloc>();
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => BlocProvider.value(value: bloc, child: OrganizationDetailPage(organizationId: orgId)),
));
}
void _showCreateOrganizationDialog() {
final bloc = context.read<OrganizationsBloc>();
showDialog(
context: context,
builder: (_) => BlocProvider.value(value: bloc, child: const CreateOrganizationDialog()),
);
}
void _showEditOrganizationDialog(OrganizationModel org) {
final bloc = context.read<OrganizationsBloc>();
showDialog(
context: context,
builder: (_) => BlocProvider.value(value: bloc, child: EditOrganizationDialog(organization: org)),
);
}
void _confirmDeleteOrganization(OrganizationModel org) {
final bloc = context.read<OrganizationsBloc>();
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'),
),
],
),
);
}
}