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(); final ScrollController _scrollController = ScrollController();
String _searchQuery = ''; String _searchQuery = '';
String _filterStatus = 'Tous'; String _filterStatus = 'Tous';
String _filterRole = 'Tous';
Timer? _searchDebounce; Timer? _searchDebounce;
bool _isSearchExpanded = false;
bool _isGridView = false; bool _isGridView = false;
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; 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 { List<Map<String, dynamic>> get _filteredMembers {
return _accumulatedMembers.where((m) { return _accumulatedMembers.where((m) {
final matchesSearch = _searchQuery.isEmpty || final matchesSearch = _searchQuery.isEmpty ||
@@ -151,7 +158,9 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
(m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) || (m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
(m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()); (m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
final matchesStatus = _filterStatus == 'Tous' || m['status'] == _filterStatus; 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(); }).toList();
} }
@@ -167,15 +176,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
body: Column( body: Column(
children: [ children: [
_buildKpiHeader(context), _buildKpiHeader(context),
AnimatedCrossFade( _buildSearchAndFilters(context),
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),
),
Expanded(child: _buildContent(context)), Expanded(child: _buildContent(context)),
], ],
), ),
@@ -185,30 +186,10 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
// ─── AppBar principale ──────────────────────────────────────────────────── // ─── AppBar principale ────────────────────────────────────────────────────
PreferredSizeWidget _buildMainAppBar() { PreferredSizeWidget _buildMainAppBar() {
final hasFilter = _filterStatus != 'Tous' || _searchQuery.isNotEmpty;
return UFAppBar( return UFAppBar(
title: 'Annuaire Membres', title: 'Annuaire Membres',
moduleGradient: ModuleColors.membresGradient, moduleGradient: ModuleColors.membresGradient,
actions: [ 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( IconButton(
icon: Icon(_isGridView ? Icons.view_list_outlined : Icons.grid_view_outlined), icon: Icon(_isGridView ? Icons.view_list_outlined : Icons.grid_view_outlined),
onPressed: () => setState(() => _isGridView = !_isGridView), onPressed: () => setState(() => _isGridView = !_isGridView),
@@ -220,6 +201,14 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
onPressed: widget.onAddMember, onPressed: widget.onAddMember,
tooltip: 'Ajouter un membre', tooltip: 'Ajouter un membre',
), ),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() => _accumulatedMembers.clear());
widget.onRefresh();
},
tooltip: 'Rafraîchir',
),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
); );
@@ -337,9 +326,10 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
Widget _kpiSeparator(BuildContext context) => Widget _kpiSeparator(BuildContext context) =>
Container(width: 1, height: 42, color: Theme.of(context).colorScheme.outlineVariant); 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( return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -348,7 +338,9 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Barre de recherche
TextField( TextField(
controller: _searchController, controller: _searchController,
onChanged: (v) { onChanged: (v) {
@@ -364,10 +356,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
hintStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant), hintStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, size: 20, color: ModuleColors.membres), prefixIcon: Icon(Icons.search, size: 20, color: ModuleColors.membres),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: _clearSearch)
icon: const Icon(Icons.clear, size: 18),
onPressed: _clearSearch,
)
: null, : null,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)), 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), const SizedBox(height: 8),
// Chips statut
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: ['Tous', 'Actif', 'Inactif', 'En attente', 'Suspendu'] children: ['Tous', 'Actif', 'Inactif', 'En attente', 'Suspendu']
.map((f) => Padding( .map((f) => Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(context, f), child: _buildFilterChip(context, f, isStatus: true),
)) ))
.toList(), .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) { Widget _buildFilterChip(BuildContext context, String label, {bool isStatus = true}) {
final isSelected = _filterStatus == label; final isSelected = isStatus ? _filterStatus == label : _filterRole == label;
final accentColor = isStatus ? ModuleColors.membres : ModuleColors.organisationsDark;
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _filterStatus = label), onTap: () => setState(() {
if (isStatus) {
_filterStatus = label;
} else {
_filterRole = label;
}
}),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? ModuleColors.membres : Colors.transparent, color: isSelected ? accentColor : Colors.transparent,
borderRadius: BorderRadius.circular(20), 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( child: Text(
label, label,
@@ -926,7 +970,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
// ─── Empty state enrichi ────────────────────────────────────────────────── // ─── Empty state enrichi ──────────────────────────────────────────────────
Widget _buildEmptyState(BuildContext context) { Widget _buildEmptyState(BuildContext context) {
final hasFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty; final hasFilters = _filterStatus != 'Tous' || _filterRole != 'Tous' || _searchQuery.isNotEmpty;
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(40), padding: const EdgeInsets.all(40),
@@ -959,7 +1003,7 @@ class _MembersPageState extends State<MembersPageWithDataAndPagination> {
if (hasFilters) if (hasFilters)
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
setState(() { _filterStatus = 'Tous'; _searchQuery = ''; _searchController.clear(); }); setState(() { _filterStatus = 'Tous'; _filterRole = 'Tous'; _searchQuery = ''; _searchController.clear(); });
widget.onSearch?.call(null); widget.onSearch?.call(null);
}, },
icon: const Icon(Icons.filter_list_off, size: 18), icon: const Icon(Icons.filter_list_off, size: 18),