3 fonctionnalités ajoutées pour aligner avec l'UX de l'Annuaire Membres : 1. Filtres par statut (chips) : Tous, Active, En création, Inactive, Suspendue, Dissoute Chips horizontaux scrollables sous la barre de recherche, filtre local (combiné avec le filtre par type de la TabBar + recherche texte) 2. Vue grille toggle : bouton grid/list dans l'AppBar Grid 2 colonnes avec cartes compactes (icône, nom, statut, type, localisation, membres) 3. Swipe actions : swipe DROIT → modifier, swipe GAUCHE → supprimer Uniquement pour SuperAdmin/OrgAdmin (canManage=true) Background coloré avec icônes + labels confirmDismiss retourne false → la carte ne disparaît pas
842 lines
35 KiB
Dart
842 lines
35 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 = [];
|
|
|
|
// ── Nouvelles fonctionnalités (parité Annuaire Membres) ──
|
|
bool _isGridView = false;
|
|
String _filterStatut = 'Tous';
|
|
static const _statutFilters = ['Tous', 'Active', 'En création', 'Inactive', 'Suspendue', 'Dissoute'];
|
|
static const _statutFilterMapping = {
|
|
'Active': 'ACTIVE',
|
|
'En création': 'EN_CREATION',
|
|
'Inactive': 'INACTIVE',
|
|
'Suspendue': 'SUSPENDUE',
|
|
'Dissoute': 'DISSOUTE',
|
|
};
|
|
|
|
/// 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: Icon(_isGridView ? Icons.view_list_rounded : Icons.grid_view_rounded),
|
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
|
tooltip: _isGridView ? 'Vue liste' : 'Vue grille',
|
|
),
|
|
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.sm),
|
|
_buildStatutChips(context),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
AnimatedSlideIn(
|
|
duration: const Duration(milliseconds: 800),
|
|
curve: Curves.easeOut,
|
|
child: _isGridView
|
|
? _buildOrganizationsGrid(context, state)
|
|
: _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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Chips de filtre par statut ─────────────────────────────────────────
|
|
|
|
Widget _buildStatutChips(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: _statutFilters.map((label) {
|
|
final isSelected = _filterStatut == label;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: GestureDetector(
|
|
onTap: () => setState(() => _filterStatut = label),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? ModuleColors.organisations : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isSelected ? ModuleColors.organisations : Theme.of(context).colorScheme.outline,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected ? AppColors.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Filtre les organisations par statut (chips) en plus des filtres BLoC (type + recherche)
|
|
List<OrganizationModel> _applyStatutFilter(List<OrganizationModel> orgs) {
|
|
if (_filterStatut == 'Tous') return orgs;
|
|
final mappedStatut = _statutFilterMapping[_filterStatut];
|
|
if (mappedStatut == null) return orgs;
|
|
return orgs.where((o) => o.statut.name.toUpperCase() == mappedStatut).toList();
|
|
}
|
|
|
|
// ─── Liste des organisations (avec swipe) ─────────────────────────────────
|
|
|
|
Widget _buildOrganizationsList(BuildContext context, OrganizationsLoaded state) {
|
|
final organizations = _applyStatutFilter(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: _buildSwipeableCard(context, org, canManageOrgs),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ─── Vue grille ───────────────────────────────────────────────────────────
|
|
|
|
Widget _buildOrganizationsGrid(BuildContext context, OrganizationsLoaded state) {
|
|
final organizations = _applyStatutFilter(state.filteredOrganizations);
|
|
if (organizations.isEmpty) return _buildEmptyState(context);
|
|
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 0.85,
|
|
),
|
|
itemCount: organizations.length,
|
|
itemBuilder: (context, i) {
|
|
final org = organizations[i];
|
|
return _buildGridCard(context, org);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildGridCard(BuildContext context, OrganizationModel org) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final statutColor = Color(int.parse(org.statut.color.substring(1), radix: 16) + 0xFF000000);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _showOrganizationDetails(org),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: scheme.outline.withOpacity(0.3)),
|
|
boxShadow: [BoxShadow(color: AppColors.shadow, blurRadius: 4, offset: const Offset(0, 2))],
|
|
),
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Icône + statut
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: ModuleColors.organisations.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.business_outlined, size: 20, color: ModuleColors.organisations),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: statutColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
org.statut.displayName,
|
|
style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: statutColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
// Nom
|
|
Text(
|
|
org.nom,
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: scheme.onSurface),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (org.nomCourt?.isNotEmpty == true)
|
|
Text(org.nomCourt!, style: TextStyle(fontSize: 10, color: scheme.onSurfaceVariant)),
|
|
const Spacer(),
|
|
// Type
|
|
Text(
|
|
org.typeOrganisation,
|
|
style: TextStyle(fontSize: 10, color: ModuleColors.organisations, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Localisation + membres
|
|
Row(
|
|
children: [
|
|
if (org.ville?.isNotEmpty == true) ...[
|
|
Icon(Icons.location_on_outlined, size: 11, color: scheme.onSurfaceVariant),
|
|
const SizedBox(width: 2),
|
|
Expanded(
|
|
child: Text(
|
|
org.ville!,
|
|
style: TextStyle(fontSize: 10, color: scheme.onSurfaceVariant),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
Icon(Icons.people_outline, size: 11, color: scheme.onSurfaceVariant),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'${org.nombreMembres}',
|
|
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: scheme.onSurface),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Swipe actions ────────────────────────────────────────────────────────
|
|
|
|
Widget _buildSwipeableCard(BuildContext context, OrganizationModel org, bool canManage) {
|
|
if (!canManage) {
|
|
return OrganizationCard(
|
|
organization: org,
|
|
onTap: () => _showOrganizationDetails(org),
|
|
showActions: false,
|
|
);
|
|
}
|
|
|
|
return Dismissible(
|
|
key: Key('org_swipe_${org.id}'),
|
|
direction: DismissDirection.horizontal,
|
|
confirmDismiss: (dir) async {
|
|
if (dir == DismissDirection.startToEnd) {
|
|
_showEditOrganizationDialog(org);
|
|
} else if (dir == DismissDirection.endToStart) {
|
|
_confirmDeleteOrganization(org);
|
|
}
|
|
return false;
|
|
},
|
|
background: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: ModuleColors.organisations.withOpacity(0.13),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: const Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.edit_outlined, color: ModuleColors.organisations, size: 22),
|
|
SizedBox(height: 4),
|
|
Text('Modifier', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: ModuleColors.organisations)),
|
|
],
|
|
),
|
|
),
|
|
secondaryBackground: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error.withOpacity(0.13),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.delete_outline, color: AppColors.error, size: 22),
|
|
const SizedBox(height: 4),
|
|
Text('Supprimer', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.error)),
|
|
],
|
|
),
|
|
),
|
|
child: OrganizationCard(
|
|
organization: org,
|
|
onTap: () => _showOrganizationDetails(org),
|
|
onEdit: () => _showEditOrganizationDialog(org),
|
|
onDelete: () => _confirmDeleteOrganization(org),
|
|
showActions: canManage,
|
|
),
|
|
);
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|