feat(members): filtres toujours visibles + filtre par rôle + refresh AppBar

Améliorations UX Annuaire Membres (parité avec Gestion Organisations) :

1. Panneau recherche/filtres toujours visible (plus de toggle collapsible)
   → UX plus directe, pas de clic supplémentaire pour chercher

2. Filtre par rôle (nouveau) : chips Admin, Modérateur, Membre Actif, Membre Simple
   avec icône badge devant la rangée, couleur accent différente du statut

3. Bouton Refresh ajouté dans l'AppBar (cohérence avec la page Organisations)

4. Lien 'Réinitialiser les filtres' visible quand au moins un filtre est actif

5. _filteredMembers intègre le filtre rôle via _roleFilterMapping

Supprimé :
- _isSearchExpanded (plus utile — panneau toujours visible)
- Bouton toggle search dans l'AppBar (remplacé par affichage permanent)
This commit is contained in:
dahoud
2026-04-16 15:05:07 +00:00
parent 989b411afe
commit 14ecdd642b

View File

@@ -63,9 +63,9 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
String _filterStatus = 'Tous';
String _filterRole = 'Tous';
Timer? _searchDebounce;
bool _isSearchExpanded = false;
bool _isGridView = false;
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
@@ -144,6 +144,13 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
});
}
static const _roleFilterMapping = {
'Admin': 'Administrateur Org',
'Modérateur': 'Modérateur',
'Membre Actif': 'Membre Actif',
'Membre Simple': 'Membre Simple',
};
List<Map<String, dynamic>> get _filteredMembers {
return _accumulatedMembers.where((m) {
final matchesSearch = _searchQuery.isEmpty ||
@@ -151,7 +158,9 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
(m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
(m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
final matchesStatus = _filterStatus == 'Tous' || m['status'] == _filterStatus;
return matchesSearch && matchesStatus;
final matchesRole = _filterRole == 'Tous' ||
(m['role'] as String? ?? '') == _roleFilterMapping[_filterRole];
return matchesSearch && matchesStatus && matchesRole;
}).toList();
}
@@ -167,15 +176,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
body: Column(
children: [
_buildKpiHeader(context),
AnimatedCrossFade(
duration: const Duration(milliseconds: 220),
firstCurve: Curves.easeInOut,
secondCurve: Curves.easeInOut,
sizeCurve: Curves.easeInOut,
crossFadeState: _isSearchExpanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
firstChild: _buildSearchPanel(context),
secondChild: const SizedBox(width: double.infinity, height: 0),
),
_buildSearchAndFilters(context),
Expanded(child: _buildContent(context)),
],
),
@@ -185,30 +186,10 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
// ─── AppBar principale ────────────────────────────────────────────────────
PreferredSizeWidget _buildMainAppBar() {
final hasFilter = _filterStatus != 'Tous' || _searchQuery.isNotEmpty;
return UFAppBar(
title: 'Annuaire Membres',
moduleGradient: ModuleColors.membresGradient,
actions: [
Stack(
children: [
IconButton(
icon: Icon(_isSearchExpanded ? Icons.search_off : Icons.search),
onPressed: () => setState(() => _isSearchExpanded = !_isSearchExpanded),
tooltip: 'Recherche & filtres',
),
if (hasFilter)
Positioned(
right: 10,
top: 10,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: AppColors.warning, shape: BoxShape.circle),
),
),
],
),
IconButton(
icon: Icon(_isGridView ? Icons.view_list_outlined : Icons.grid_view_outlined),
onPressed: () => setState(() => _isGridView = !_isGridView),
@@ -220,6 +201,14 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
onPressed: widget.onAddMember,
tooltip: 'Ajouter un membre',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() => _accumulatedMembers.clear());
widget.onRefresh();
},
tooltip: 'Rafraîchir',
),
const SizedBox(width: 4),
],
);
@@ -337,9 +326,10 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
Widget _kpiSeparator(BuildContext context) =>
Container(width: 1, height: 42, color: Theme.of(context).colorScheme.outlineVariant);
// ─── Panneau recherche collapsible ────────────────────────────────────────
// ─── Recherche + Filtres (toujours visibles) ────────────────────────────
Widget _buildSearchPanel(BuildContext context) {
Widget _buildSearchAndFilters(BuildContext context) {
final hasFilter = _filterStatus != 'Tous' || _filterRole != 'Tous' || _searchQuery.isNotEmpty;
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
@@ -348,7 +338,9 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche
TextField(
controller: _searchController,
onChanged: (v) {
@@ -364,10 +356,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
hintStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, size: 20, color: ModuleColors.membres),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: _clearSearch,
)
? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: _clearSearch)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
@@ -378,33 +367,88 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
),
),
const SizedBox(height: 8),
// Chips statut
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ['Tous', 'Actif', 'Inactif', 'En attente', 'Suspendu']
.map((f) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(context, f),
child: _buildFilterChip(context, f, isStatus: true),
))
.toList(),
),
),
const SizedBox(height: 6),
// Chips rôle
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(Icons.badge_outlined, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
...['Tous', 'Admin', 'Modérateur', 'Membre Actif', 'Membre Simple']
.map((f) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(context, f, isStatus: false),
))
.toList(),
],
),
),
// Lien réinitialiser
if (hasFilter)
Padding(
padding: const EdgeInsets.only(top: 6),
child: GestureDetector(
onTap: () {
setState(() {
_filterStatus = 'Tous';
_filterRole = 'Tous';
_searchQuery = '';
_searchController.clear();
});
widget.onSearch?.call(null);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.filter_list_off, size: 13, color: ModuleColors.membres),
const SizedBox(width: 4),
Text('Réinitialiser les filtres',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: ModuleColors.membres)),
],
),
),
),
],
),
);
}
Widget _buildFilterChip(BuildContext context, String label) {
final isSelected = _filterStatus == label;
Widget _buildFilterChip(BuildContext context, String label, {bool isStatus = true}) {
final isSelected = isStatus ? _filterStatus == label : _filterRole == label;
final accentColor = isStatus ? ModuleColors.membres : ModuleColors.organisationsDark;
return GestureDetector(
onTap: () => setState(() => _filterStatus = label),
onTap: () => setState(() {
if (isStatus) {
_filterStatus = label;
} else {
_filterRole = label;
}
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? ModuleColors.membres : Colors.transparent,
color: isSelected ? accentColor : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outline, width: 1),
border: Border.all(color: isSelected ? accentColor : Theme.of(context).colorScheme.outline, width: 1),
),
child: Text(
label,
@@ -926,7 +970,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
// ─── Empty state enrichi ──────────────────────────────────────────────────
Widget _buildEmptyState(BuildContext context) {
final hasFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty;
final hasFilters = _filterStatus != 'Tous' || _filterRole != 'Tous' || _searchQuery.isNotEmpty;
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
@@ -959,7 +1003,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
if (hasFilters)
FilledButton.icon(
onPressed: () {
setState(() { _filterStatus = 'Tous'; _searchQuery = ''; _searchController.clear(); });
setState(() { _filterStatus = 'Tous'; _filterRole = 'Tous'; _searchQuery = ''; _searchController.clear(); });
widget.onSearch?.call(null);
},
icon: const Icon(Icons.filter_list_off, size: 18),