feat(organizations): parité UX avec Annuaire Membres — filtres statut + grille + swipe

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
This commit is contained in:
dahoud
2026-04-16 14:58:29 +00:00
parent f74f13c174
commit 989b411afe

View File

@@ -33,6 +33,18 @@ class _OrganizationsPageState extends State<OrganizationsPage> with TickerProvid
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
List<String?> _availableTypes = []; 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. /// Cache de la dernière liste connue.
/// Évite de perdre l'affichage quand le bloc passe dans un état non-liste /// Évite de perdre l'affichage quand le bloc passe dans un état non-liste
/// (OrganizationLoaded, OrganizationCreated, etc.) après navigation vers /// (OrganizationLoaded, OrganizationCreated, etc.) après navigation vers
@@ -152,6 +164,11 @@ class _OrganizationsPageState extends State<OrganizationsPage> with TickerProvid
moduleGradient: ModuleColors.organisationsGradient, moduleGradient: ModuleColors.organisationsGradient,
elevation: 0, elevation: 0,
actions: [ actions: [
IconButton(
icon: Icon(_isGridView ? Icons.view_list_rounded : Icons.grid_view_rounded),
onPressed: () => setState(() => _isGridView = !_isGridView),
tooltip: _isGridView ? 'Vue liste' : 'Vue grille',
),
IconButton( IconButton(
icon: const Icon(Icons.category_outlined), icon: const Icon(Icons.category_outlined),
onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const OrgTypesPage())), onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const OrgTypesPage())),
@@ -266,11 +283,15 @@ class _OrganizationsPageState extends State<OrganizationsPage> with TickerProvid
curve: Curves.easeOut, curve: Curves.easeOut,
child: _buildSearchBar(context, state), child: _buildSearchBar(context, state),
), ),
const SizedBox(height: SpacingTokens.sm),
_buildStatutChips(context),
const SizedBox(height: SpacingTokens.md), const SizedBox(height: SpacingTokens.md),
AnimatedSlideIn( AnimatedSlideIn(
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
curve: Curves.easeOut, curve: Curves.easeOut,
child: _buildOrganizationsList(context, state), child: _isGridView
? _buildOrganizationsGrid(context, state)
: _buildOrganizationsList(context, state),
), ),
const SizedBox(height: 80), const SizedBox(height: 80),
], ],
@@ -390,10 +411,57 @@ class _OrganizationsPageState extends State<OrganizationsPage> with TickerProvid
); );
} }
// ─── Liste des organisations ────────────────────────────────────────────── // ─── 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) { Widget _buildOrganizationsList(BuildContext context, OrganizationsLoaded state) {
final organizations = state.filteredOrganizations; final organizations = _applyStatutFilter(state.filteredOrganizations);
if (organizations.isEmpty) return _buildEmptyState(context); if (organizations.isEmpty) return _buildEmptyState(context);
final authState = context.read<AuthBloc>().state; final authState = context.read<AuthBloc>().state;
@@ -409,16 +477,187 @@ class _OrganizationsPageState extends State<OrganizationsPage> with TickerProvid
final org = organizations[i]; final org = organizations[i];
return AnimatedFadeIn( return AnimatedFadeIn(
duration: Duration(milliseconds: 300 + (i * 50)), 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( child: OrganizationCard(
organization: org, organization: org,
onTap: () => _showOrganizationDetails(org), onTap: () => _showOrganizationDetails(org),
onEdit: canManageOrgs ? () => _showEditOrganizationDialog(org) : null, onEdit: () => _showEditOrganizationDialog(org),
onDelete: canManageOrgs ? () => _confirmDeleteOrganization(org) : null, onDelete: () => _confirmDeleteOrganization(org),
showActions: canManageOrgs, showActions: canManage,
), ),
); );
},
);
} }
Widget _buildLoadingMorePlaceholder(BuildContext context, List<OrganizationModel> orgs) { Widget _buildLoadingMorePlaceholder(BuildContext context, List<OrganizationModel> orgs) {