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:
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user