Files
unionflow-mobile-apps/lib/features/members/presentation/pages/members_page_connected.dart
dahoud 14ecdd642b 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)
2026-04-16 15:05:07 +00:00

1805 lines
73 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/components/uf_app_bar.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../features/organizations/domain/repositories/organization_repository.dart';
/// Annuaire des Membres — Design UnionFlow v2
///
/// Fonctionnalités :
/// - Identité violet #7616E8
/// - Header KPI 4 métriques (Total, % Actifs, En attente, Cotisations)
/// - Recherche inline collapsible + filtres chips
/// - Scroll infini (append pages au fur et à mesure)
/// - Mode grille / liste toggle
/// - Swipe gauche → Reset MDP | Swipe droit → Affecter org
/// - Long press → sélection multiple (Exporter, Affecter org)
/// - Navigation page complète au lieu d'une bottom sheet
class MembersPageWithDataAndPagination extends StatefulWidget {
final List<Map<String, dynamic>> members;
final int totalCount;
final int currentPage;
final int totalPages;
final Function(int page, String? recherche) onPageChanged;
final VoidCallback onRefresh;
final void Function(String? query)? onSearch;
final VoidCallback? onAddMember;
/// null = SUPER_ADMIN (vue globale, affiche l'organisation sur chaque carte)
final String? organisationId;
final void Function(String memberId)? onActivateMember;
final void Function(String memberId)? onResetPassword;
final void Function(String memberId, String organisationId)? onAffecterOrganisation;
final void Function(String memberId, String organisationId, String action, String? motif)? onLifecycleAction;
/// Suppression définitive du compte (SuperAdmin uniquement — backend @RolesAllowed ADMIN/SUPER_ADMIN).
final void Function(String memberId)? onDeleteAccount;
const MembersPageWithDataAndPagination({
super.key,
required this.members,
required this.totalCount,
required this.currentPage,
required this.totalPages,
required this.onPageChanged,
required this.onRefresh,
this.onSearch,
this.onAddMember,
this.organisationId,
this.onActivateMember,
this.onResetPassword,
this.onAffecterOrganisation,
this.onLifecycleAction,
this.onDeleteAccount,
});
@override
State<MembersPageWithDataAndPagination> createState() => _MembersPageState();
}
class _MembersPageState extends State<MembersPageWithDataAndPagination> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
String _filterStatus = 'Tous';
String _filterRole = 'Tous';
Timer? _searchDebounce;
bool _isGridView = false;
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
bool _isLoadingMore = false;
List<Map<String, dynamic>> _accumulatedMembers = [];
List<Map<String, String>> _organisationsPicker = [];
bool get _isSuperAdmin => widget.organisationId == null;
@override
void initState() {
super.initState();
_accumulatedMembers = List.from(widget.members);
_scrollController.addListener(_onScroll);
}
@override
void didUpdateWidget(covariant MembersPageWithDataAndPagination old) {
super.didUpdateWidget(old);
if (old.members != widget.members) {
setState(() {
if (widget.currentPage == 0) {
_accumulatedMembers = List.from(widget.members);
} else {
// Append page suivante (dédoublonnage par id)
final existingIds = _accumulatedMembers.map((m) => m['id'] as String? ?? '').toSet();
final newItems = widget.members.where((m) => !existingIds.contains(m['id'] as String? ?? '')).toList();
_accumulatedMembers.addAll(newItems);
}
_isLoadingMore = false;
});
}
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 200) {
if (!_isLoadingMore && widget.currentPage < widget.totalPages - 1) {
setState(() => _isLoadingMore = true);
widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery);
}
}
}
void _clearSearch() {
_searchDebounce?.cancel();
_searchController.clear();
setState(() => _searchQuery = '');
widget.onSearch?.call(null);
}
void _toggleSelection(String id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
if (_selectedIds.isEmpty) _isSelectionMode = false;
} else {
_selectedIds.add(id);
}
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
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 ||
(m['name'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
(m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
(m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
final matchesStatus = _filterStatus == 'Tous' || m['status'] == _filterStatus;
final matchesRole = _filterRole == 'Tous' ||
(m['role'] as String? ?? '') == _roleFilterMapping[_filterRole];
return matchesSearch && matchesStatus && matchesRole;
}).toList();
}
// ═══════════════════════════════════════════════════════════════════════════
// Build
// ═══════════════════════════════════════════════════════════════════════════
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildMainAppBar(),
body: Column(
children: [
_buildKpiHeader(context),
_buildSearchAndFilters(context),
Expanded(child: _buildContent(context)),
],
),
);
}
// ─── AppBar principale ────────────────────────────────────────────────────
PreferredSizeWidget _buildMainAppBar() {
return UFAppBar(
title: 'Annuaire Membres',
moduleGradient: ModuleColors.membresGradient,
actions: [
IconButton(
icon: Icon(_isGridView ? Icons.view_list_outlined : Icons.grid_view_outlined),
onPressed: () => setState(() => _isGridView = !_isGridView),
tooltip: _isGridView ? 'Vue liste' : 'Vue grille',
),
if (widget.onAddMember != null)
IconButton(
icon: const Icon(Icons.person_add_outlined),
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),
],
);
}
// ─── AppBar sélection multiple ────────────────────────────────────────────
PreferredSizeWidget _buildSelectionAppBar() {
return AppBar(
backgroundColor: ModuleColors.membres,
foregroundColor: AppColors.onPrimary,
elevation: 0,
iconTheme: const IconThemeData(color: AppColors.onPrimary),
actionsIconTheme: const IconThemeData(color: AppColors.onPrimary),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _exitSelectionMode,
),
title: Text(
'${_selectedIds.length} sélectionné(s)',
style: const TextStyle(color: AppColors.onPrimary, fontWeight: FontWeight.w600),
),
actions: [
if (_selectedIds.isNotEmpty && widget.onAffecterOrganisation != null && _isSuperAdmin)
IconButton(
icon: const Icon(Icons.business_outlined),
tooltip: 'Affecter à une organisation',
onPressed: _showBulkAffecterDialog,
),
if (_selectedIds.isNotEmpty)
IconButton(
icon: const Icon(Icons.download_outlined),
tooltip: 'Exporter la sélection',
onPressed: _bulkExporter,
),
const SizedBox(width: 4),
],
);
}
// ─── Header KPI ────────────────────────────────────────────────────────────
Widget _buildKpiHeader(BuildContext context) {
final total = widget.totalCount;
final displayed = _accumulatedMembers.length;
final activeCount = _accumulatedMembers.where((m) => m['status'] == 'Actif').length;
final pendingCount = _accumulatedMembers.where((m) => m['status'] == 'En attente').length;
final cotisOkCount = _accumulatedMembers.where((m) => m['cotisationAJour'] == true).length;
final activePercent = displayed > 0 ? activeCount / displayed : 0.0;
final cotisPercent = displayed > 0 ? cotisOkCount / displayed : 0.0;
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 1)),
),
child: Row(
children: [
_kpiTile(context, 'Total', total.toString(), ModuleColors.membres, Icons.people_outline),
_kpiSeparator(context),
_kpiTileProgress(context, 'Actifs', '${(activePercent * 100).toStringAsFixed(0)}%', AppColors.success, activePercent),
_kpiSeparator(context),
_kpiTile(context, 'Attente', pendingCount.toString(), AppColors.warning, Icons.schedule_outlined),
_kpiSeparator(context),
_kpiTileProgress(
context,
'Cotisations',
'${(cotisPercent * 100).toStringAsFixed(0)}%',
cotisPercent >= 0.7 ? AppColors.success : AppColors.error,
cotisPercent,
),
],
),
);
}
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 _kpiSeparator(BuildContext context) =>
Container(width: 1, height: 42, color: Theme.of(context).colorScheme.outlineVariant);
// ─── Recherche + Filtres (toujours visibles) ────────────────────────────
Widget _buildSearchAndFilters(BuildContext context) {
final hasFilter = _filterStatus != 'Tous' || _filterRole != 'Tous' || _searchQuery.isNotEmpty;
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 1)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche
TextField(
controller: _searchController,
onChanged: (v) {
setState(() => _searchQuery = v);
_searchDebounce?.cancel();
_searchDebounce = Timer(AppConstants.searchDebounce, () {
widget.onSearch?.call(v.isEmpty ? null : v);
});
},
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Nom, email, numéro membre...',
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)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide(color: ModuleColors.membres, width: 1.5)),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
),
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, 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, {bool isStatus = true}) {
final isSelected = isStatus ? _filterStatus == label : _filterRole == label;
final accentColor = isStatus ? ModuleColors.membres : ModuleColors.organisationsDark;
return GestureDetector(
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 ? accentColor : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? accentColor : 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,
),
),
),
);
}
// ─── Contenu principal ────────────────────────────────────────────────────
Widget _buildContent(BuildContext context) {
final filtered = _filteredMembers;
return RefreshIndicator(
onRefresh: () async {
setState(() => _accumulatedMembers.clear());
widget.onRefresh();
},
color: ModuleColors.membres,
child: filtered.isEmpty
? SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: _buildEmptyState(context),
)
: _isGridView
? _buildGridView(context, filtered)
: _buildListView(context, filtered),
);
}
Widget _buildListView(BuildContext context, List<Map<String, dynamic>> members) {
return ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: members.length + (_isLoadingMore ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
if (i == members.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(color: ModuleColors.membres),
),
);
}
return _buildSwipeableCard(context, members[i]);
},
);
}
Widget _buildGridView(BuildContext context, List<Map<String, dynamic>> members) {
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.82,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: members.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == members.length) {
return const Center(child: CircularProgressIndicator(color: ModuleColors.membres));
}
return _buildGridCard(context, members[i]);
},
);
}
// ─── Carte liste avec swipe ────────────────────────────────────────────────
/// Détermine l'action swipe gauche pour l'orgAdmin selon le statut du membre.
/// Retourne null si aucune action n'est disponible (rôle admin, statut final, etc.).
_SwipeAction? _getLeftSwipeAction(Map<String, dynamic> member) {
// SuperAdmin : affecter org (géré séparément)
if (_isSuperAdmin) return null;
// Pas de lifecycle disponible
if (widget.onLifecycleAction == null) return null;
// Pas d'action sur un admin/superadmin
final roleCode = (member['roleCode'] as String? ?? '').toUpperCase();
if (roleCode == 'ORGADMIN' || roleCode == 'SUPERADMIN' ||
roleCode == 'ADMIN_ORGANISATION' || roleCode == 'SUPER_ADMIN') {
return null;
}
final statut = (member['statutMembre'] as String? ??
member['status'] as String? ?? '')
.toUpperCase();
switch (statut) {
case 'EN_ATTENTE_VALIDATION':
case 'EN_ATTENTE':
case 'INVITE':
return const _SwipeAction(
action: 'activer',
icon: Icons.check_circle_outline,
label: 'Activer',
color: AppColors.success,
);
case 'ACTIF':
return const _SwipeAction(
action: 'suspendre',
icon: Icons.pause_circle_outline,
label: 'Suspendre',
color: AppColors.warning,
);
case 'SUSPENDU':
return const _SwipeAction(
action: 'activer',
icon: Icons.play_circle_outline,
label: 'Réactiver',
color: AppColors.info,
);
default:
// RADIE, ARCHIVE, etc. → pas d'action rapide
return null;
}
}
Widget _buildSwipeableCard(BuildContext context, Map<String, dynamic> member) {
final id = member['id'] as String? ?? '';
if (_isSelectionMode) {
return _buildListCard(context, member, isSelected: _selectedIds.contains(id));
}
// Swipe droite → Reset MDP (tous rôles admin si callback fourni)
final canSwipeRight = widget.onResetPassword != null;
// Swipe gauche → SuperAdmin: affecter org | OrgAdmin: lifecycle selon statut
final bool canSwipeLeftSuperAdmin =
_isSuperAdmin && widget.onAffecterOrganisation != null;
final _SwipeAction? leftAction = _getLeftSwipeAction(member);
final bool canSwipeLeft = canSwipeLeftSuperAdmin || leftAction != null;
if (!canSwipeRight && !canSwipeLeft) {
return _buildListCard(context, member);
}
// Background droite (Reset MDP) — toujours warning/orange
final rightBg = _swipeActionBackground(
isLeft: true,
icon: Icons.lock_reset_outlined,
label: 'Reset\nMDP',
color: AppColors.warning,
);
// Background gauche — contextuel selon rôle
Widget leftBg;
if (canSwipeLeftSuperAdmin) {
leftBg = _swipeActionBackground(
isLeft: false,
icon: Icons.business_outlined,
label: 'Affecter\nOrg',
color: ModuleColors.membres,
);
} else if (leftAction != null) {
leftBg = _swipeActionBackground(
isLeft: false,
icon: leftAction.icon,
label: leftAction.label,
color: leftAction.color,
);
} else {
leftBg = const SizedBox.shrink();
}
return Dismissible(
key: Key('swipe_$id'),
direction: canSwipeRight && canSwipeLeft
? DismissDirection.horizontal
: canSwipeRight
? DismissDirection.startToEnd
: DismissDirection.endToStart,
confirmDismiss: (dir) async {
if (dir == DismissDirection.startToEnd && canSwipeRight) {
widget.onResetPassword!(id);
} else if (dir == DismissDirection.endToStart) {
if (canSwipeLeftSuperAdmin) {
_showAffecterOrganisationDialog(context, member);
} else if (leftAction != null) {
final orgId = member['organisationId'] as String? ?? '';
if (orgId.isNotEmpty) {
widget.onLifecycleAction!(id, orgId, leftAction.action, null);
}
}
}
return false; // la carte ne disparaît pas
},
background: canSwipeRight ? rightBg : const SizedBox.shrink(),
secondaryBackground: canSwipeLeft ? leftBg : const SizedBox.shrink(),
child: _buildListCard(context, member),
);
}
Widget _swipeActionBackground({
required bool isLeft,
required IconData icon,
required String label,
required Color color,
}) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: color.withOpacity(0.13),
borderRadius: BorderRadius.circular(12),
),
alignment: isLeft ? Alignment.centerLeft : Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 22),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 10, fontWeight: FontWeight.w700, color: color),
textAlign: TextAlign.center,
),
],
),
);
}
// ─── Carte liste ──────────────────────────────────────────────────────────
Widget _buildListCard(BuildContext context, Map<String, dynamic> member, {bool isSelected = false}) {
final id = member['id'] as String? ?? '';
final status = member['status'] as String? ?? '?';
final orgName = member['organisationNom'] as String?;
final numero = member['numeroMembre'] as String?;
final cotisAJour = member['cotisationAJour'] as bool? ?? false;
final statutKyc = member['statutKyc'] as String?;
return GestureDetector(
onTap: () {
if (_isSelectionMode) {
_toggleSelection(id);
} else {
_navigateToDetail(context, member);
}
},
onLongPress: () {
if (!_isSelectionMode) {
setState(() {
_isSelectionMode = true;
_selectedIds.add(id);
});
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? ModuleColors.membres.withOpacity(0.08) : Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outlineVariant,
width: isSelected ? 1.5 : 1,
),
),
child: Row(
children: [
// Checkbox ou Avatar
if (_isSelectionMode)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? ModuleColors.membres : Colors.transparent,
border: Border.all(color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outline, width: 2),
),
child: isSelected ? const Icon(Icons.check, size: 18, color: AppColors.onPrimary) : null,
)
else
_buildAvatarWidget(member, 36),
const SizedBox(width: 12),
// Infos
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
member['name'] as String? ?? 'Inconnu',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
overflow: TextOverflow.ellipsis,
),
),
if (numero != null && numero.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
numero,
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
],
),
const SizedBox(height: 2),
Text(
member['role'] as String? ?? 'Membre',
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (_isSuperAdmin && orgName != null && orgName.isNotEmpty) ...[
const SizedBox(height: 3),
Row(
children: [
const Icon(Icons.business_outlined, size: 11, color: ModuleColors.membres),
const SizedBox(width: 4),
Expanded(
child: Text(
orgName,
style: const TextStyle(fontSize: 11, color: ModuleColors.membres, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
// Indicateurs KYC + Cotisation
const SizedBox(height: 5),
Row(
children: [
_miniIndicator(
cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined,
cotisAJour ? AppColors.success : AppColors.error,
cotisAJour ? 'Cotis. OK' : 'Cotis. NOK',
),
if (statutKyc != null) ...[
const SizedBox(width: 5),
_kycMiniIndicator(statutKyc),
],
],
),
],
),
),
const SizedBox(width: 8),
_statusBadge(status),
],
),
),
);
}
Widget _miniIndicator(IconData icon, Color color, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.3), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 3),
Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color)),
],
),
);
}
Widget _kycMiniIndicator(String statutKyc) {
final (Color color, IconData icon) = switch (statutKyc) {
'VERIFIE' => (AppColors.success, Icons.verified_user_outlined),
'EN_COURS' => (AppColors.warning, Icons.pending_outlined),
'REJETE' => (AppColors.error, Icons.gpp_bad_outlined),
_ => (AppColors.textTertiary, Icons.help_outline),
};
return _miniIndicator(icon, color, 'KYC');
}
// ─── Carte grille ─────────────────────────────────────────────────────────
Widget _buildGridCard(BuildContext context, Map<String, dynamic> member) {
final id = member['id'] as String? ?? '';
final status = member['status'] as String? ?? '?';
final cotisAJour = member['cotisationAJour'] as bool? ?? false;
final statutKyc = member['statutKyc'] as String?;
final isSelected = _selectedIds.contains(id);
return GestureDetector(
onTap: () => _isSelectionMode ? _toggleSelection(id) : _navigateToDetail(context, member),
onLongPress: () {
if (!_isSelectionMode) {
setState(() { _isSelectionMode = true; _selectedIds.add(id); });
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected ? ModuleColors.membres.withOpacity(0.08) : Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outlineVariant,
width: isSelected ? 1.5 : 1,
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
children: [
_buildAvatarWidget(member, 48),
if (isSelected)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 18,
height: 18,
decoration: const BoxDecoration(color: ModuleColors.membres, shape: BoxShape.circle),
child: const Icon(Icons.check, size: 12, color: AppColors.onPrimary),
),
),
],
),
const SizedBox(height: 8),
Text(
member['name'] as String? ?? 'Inconnu',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 5),
_statusBadge(status),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined, size: 14, color: cotisAJour ? AppColors.success : AppColors.error),
if (statutKyc != null) ...[
const SizedBox(width: 6),
Icon(
switch (statutKyc) {
'VERIFIE' => Icons.verified_user_outlined,
'EN_COURS' => Icons.pending_outlined,
_ => Icons.gpp_bad_outlined,
},
size: 14,
color: switch (statutKyc) {
'VERIFIE' => AppColors.success,
'EN_COURS' => AppColors.warning,
_ => AppColors.error,
},
),
],
],
),
],
),
),
),
);
}
// ─── Avatar violet ────────────────────────────────────────────────────────
Widget _buildAvatarWidget(Map<String, dynamic> member, double size) {
return Container(
width: size,
height: size,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [ModuleColors.membresDark, ModuleColors.membres], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
member['initiales'] as String? ?? '??',
style: TextStyle(color: AppColors.onPrimary, fontWeight: FontWeight.w700, fontSize: size * 0.36),
),
);
}
// ─── Badge statut ─────────────────────────────────────────────────────────
Widget _statusBadge(String status) {
final (Color color, IconData icon) = switch (status) {
'Actif' => (AppColors.success, Icons.check_circle_outline),
'Inactif' => (AppColors.error, Icons.cancel_outlined),
'En attente' => (AppColors.warning, Icons.schedule_outlined),
'Suspendu' => (AppColors.textTertiary, Icons.block_outlined),
_ => (AppColors.textTertiary, Icons.help_outline),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 3),
Text(status, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)),
],
),
);
}
// ─── Empty state enrichi ──────────────────────────────────────────────────
Widget _buildEmptyState(BuildContext context) {
final hasFilters = _filterStatus != 'Tous' || _filterRole != 'Tous' || _searchQuery.isNotEmpty;
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: ModuleColors.membres.withOpacity(0.08), shape: BoxShape.circle),
child: Icon(
hasFilters ? Icons.filter_list_off : Icons.people_outline,
size: 44,
color: ModuleColors.membres,
),
),
const SizedBox(height: 20),
Text(
hasFilters ? 'Aucun résultat' : 'Aucun membre',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface),
),
const SizedBox(height: 8),
Text(
hasFilters
? 'Aucun membre ne correspond à votre recherche ou filtre.'
: 'Aucun membre enregistré pour le moment.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, height: 1.5, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
if (hasFilters)
FilledButton.icon(
onPressed: () {
setState(() { _filterStatus = 'Tous'; _filterRole = 'Tous'; _searchQuery = ''; _searchController.clear(); });
widget.onSearch?.call(null);
},
icon: const Icon(Icons.filter_list_off, size: 18),
label: const Text('Effacer les filtres'),
style: FilledButton.styleFrom(backgroundColor: ModuleColors.membres),
)
else if (widget.onAddMember != null)
FilledButton.icon(
onPressed: widget.onAddMember,
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Ajouter un membre'),
style: FilledButton.styleFrom(backgroundColor: ModuleColors.membres),
),
],
),
),
);
}
// ─── Navigation vers page de détail ──────────────────────────────────────
void _navigateToDetail(BuildContext context, Map<String, dynamic> member) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => MemberDetailPage(
member: member,
isSuperAdmin: _isSuperAdmin,
onActivateMember: widget.onActivateMember,
onResetPassword: widget.onResetPassword,
onAffecterOrganisation: widget.onAffecterOrganisation,
onLifecycleAction: widget.onLifecycleAction,
onDeleteAccount: widget.onDeleteAccount,
organisationsPicker: _organisationsPicker,
onOrganisationsLoaded: (orgs) => setState(() => _organisationsPicker = orgs),
),
));
}
// ─── Bulk actions ─────────────────────────────────────────────────────────
Future<void> _showBulkAffecterDialog() async {
await _loadOrganisationsPicker();
if (!mounted) return;
String? selectedOrgId;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text('Affecter ${_selectedIds.length} membre(s)'),
content: _organisationsPicker.isEmpty
? const Text('Aucune organisation disponible.')
: DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Organisation'),
items: _organisationsPicker
.map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!)))
.toList(),
onChanged: (v) => set(() => selectedOrgId = v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary),
onPressed: selectedOrgId == null
? null
: () {
Navigator.pop(ctx);
for (final id in _selectedIds) {
widget.onAffecterOrganisation!(id, selectedOrgId!);
}
_exitSelectionMode();
},
child: const Text('Confirmer'),
),
],
),
),
);
}
void _bulkExporter() {
final count = _selectedIds.length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Export de $count membre(s) — fonctionnalité à venir'),
backgroundColor: ModuleColors.membres,
duration: const Duration(seconds: 3),
),
);
_exitSelectionMode();
}
Future<void> _showAffecterOrganisationDialog(BuildContext ctx, Map<String, dynamic> member) async {
await _loadOrganisationsPicker();
if (!mounted) return;
String? selectedOrgId;
await showDialog<void>(
context: ctx,
builder: (dialogCtx) => StatefulBuilder(
builder: (dialogCtx, set) => AlertDialog(
title: const Text('Affecter à une organisation'),
content: _organisationsPicker.isEmpty
? const Text('Aucune organisation disponible.')
: DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Organisation'),
items: _organisationsPicker
.map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!)))
.toList(),
onChanged: (v) => set(() => selectedOrgId = v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogCtx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary),
onPressed: selectedOrgId == null
? null
: () {
Navigator.pop(dialogCtx);
widget.onAffecterOrganisation!(member['id'] as String, selectedOrgId!);
},
child: const Text('Confirmer'),
),
],
),
),
);
}
Future<void> _loadOrganisationsPicker() async {
if (_organisationsPicker.isNotEmpty) return;
try {
final repo = GetIt.instance<IOrganizationRepository>();
final orgs = await repo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_organisationsPicker = orgs
.where((o) => o.id != null && o.id!.isNotEmpty)
.map((o) => {'id': o.id!, 'nom': o.nomAffichage})
.toList();
});
}
} catch (_) {}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// Page de détail membre (navigation complète)
// ═══════════════════════════════════════════════════════════════════════════════
class MemberDetailPage extends StatefulWidget {
final Map<String, dynamic> member;
final bool isSuperAdmin;
final void Function(String memberId)? onActivateMember;
final void Function(String memberId)? onResetPassword;
final void Function(String memberId, String organisationId)? onAffecterOrganisation;
final void Function(String memberId, String organisationId, String action, String? motif)? onLifecycleAction;
final void Function(String memberId)? onDeleteAccount;
final List<Map<String, String>> organisationsPicker;
final void Function(List<Map<String, String>> orgs) onOrganisationsLoaded;
const MemberDetailPage({
super.key,
required this.member,
required this.isSuperAdmin,
this.onActivateMember,
this.onResetPassword,
this.onAffecterOrganisation,
this.onLifecycleAction,
this.onDeleteAccount,
required this.organisationsPicker,
required this.onOrganisationsLoaded,
});
@override
State<MemberDetailPage> createState() => _MemberDetailPageState();
}
class _MemberDetailPageState extends State<MemberDetailPage> {
late List<Map<String, String>> _orgs;
@override
void initState() {
super.initState();
_orgs = List.from(widget.organisationsPicker);
}
@override
Widget build(BuildContext context) {
final m = widget.member;
final name = m['name'] as String? ?? '';
final role = m['role'] as String? ?? 'Membre';
final status = m['status'] as String? ?? '?';
final numero = m['numeroMembre'] as String?;
final email = m['email'] as String? ?? '';
final phone = m['phone'] as String? ?? '';
final orgNom = m['organisationNom'] as String?;
final dateAdhesion = m['joinDate'];
final profession = m['department'] as String? ?? '';
final nationalite = m['nationalite'] as String? ?? '';
final location = (m['location'] as String? ?? '').trim();
final cotisAJour = m['cotisationAJour'] as bool? ?? false;
final statutKyc = m['statutKyc'] as String?;
final membreBureau = m['membreBureau'] as bool? ?? false;
final responsable = m['responsable'] as bool? ?? false;
final fonctionBureau = m['fonctionBureau'] as String?;
final eventsCount = m['eventsAttended'] as int? ?? 0;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: CustomScrollView(
slivers: [
// ── Hero AppBar violet ───────────────────────────────────────────
SliverAppBar(
expandedHeight: 210,
pinned: true,
backgroundColor: ModuleColors.membres,
foregroundColor: AppColors.onPrimary,
iconTheme: const IconThemeData(color: Colors.white),
actionsIconTheme: const IconThemeData(color: Colors.white),
actions: [
if (widget.onResetPassword != null)
IconButton(
icon: const Icon(Icons.lock_reset_outlined),
tooltip: 'Réinitialiser le mot de passe',
onPressed: () {
Navigator.pop(context);
widget.onResetPassword!(m['id'] as String);
},
),
const SizedBox(width: 4),
],
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [ModuleColors.membresDark, ModuleColors.membres],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 32),
Container(
width: 74,
height: 74,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.45), width: 2.5),
gradient: const LinearGradient(
colors: [ModuleColors.membresDark, ModuleColors.membres],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
alignment: Alignment.center,
child: Text(
m['initiales'] as String? ?? '??',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 28),
),
),
const SizedBox(height: 10),
Text(name, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(role, style: TextStyle(color: Colors.white.withOpacity(0.85), fontSize: 13)),
if (numero != null && numero.isNotEmpty) ...[
Text(' · ', style: TextStyle(color: Colors.white.withOpacity(0.5))),
Text(numero, style: TextStyle(color: Colors.white.withOpacity(0.75), fontSize: 12)),
],
],
),
],
),
),
),
),
),
// ── Contenu ──────────────────────────────────────────────────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badges statut / cotisation / KYC
Wrap(
spacing: 8,
runSpacing: 6,
children: [
_detailStatusBadge(context, status),
_badgeChip(
context,
cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined,
cotisAJour ? AppColors.success : AppColors.error,
cotisAJour ? 'Cotisation OK' : 'Cotisation en retard',
),
if (statutKyc != null) _kycBadge(context, statutKyc),
if (membreBureau) _badgeChip(context, Icons.star_outline, AppColors.warningUI, fonctionBureau ?? 'Bureau'),
if (responsable) _badgeChip(context, Icons.manage_accounts_outlined, ModuleColors.membres, 'Responsable'),
],
),
const SizedBox(height: 20),
// Contact
_sectionCard(context, 'Contact', [
_detailRow(context, Icons.email_outlined, 'Email', email),
if (phone.isNotEmpty) _detailRow(context, Icons.phone_outlined, 'Téléphone', phone),
]),
const SizedBox(height: 12),
// Organisation
if (orgNom != null || dateAdhesion != null || eventsCount > 0)
_sectionCard(context, 'Organisation', [
if (orgNom != null) _detailRow(context, Icons.business_outlined, 'Organisation', orgNom),
if (dateAdhesion != null) _detailRow(context, Icons.calendar_today_outlined, 'Adhésion', _fmt(dateAdhesion)),
if (eventsCount > 0) _detailRow(context, Icons.event_outlined, 'Événements participés', eventsCount.toString()),
]),
if (orgNom != null || dateAdhesion != null || eventsCount > 0) const SizedBox(height: 12),
// Profil
if (profession.isNotEmpty || nationalite.isNotEmpty || (location != ',' && location.isNotEmpty))
_sectionCard(context, 'Profil', [
if (profession.isNotEmpty) _detailRow(context, Icons.work_outline, 'Profession', profession),
if (nationalite.isNotEmpty) _detailRow(context, Icons.flag_outlined, 'Nationalité', nationalite),
if (location.isNotEmpty && location != ',') _detailRow(context, Icons.location_on_outlined, 'Localisation', location),
]),
if (profession.isNotEmpty || nationalite.isNotEmpty || (location != ',' && location.isNotEmpty)) const SizedBox(height: 20),
// Actions
_sectionLabel(context, 'Actions'),
const SizedBox(height: 10),
if (status == 'En attente' && widget.onActivateMember != null)
_actionBtn(context, 'Activer le membre', Icons.check_circle_outline, AppColors.success, () {
Navigator.pop(context);
widget.onActivateMember!(m['id'] as String);
}),
if (widget.onResetPassword != null)
_actionBtn(context, 'Réinitialiser le mot de passe', Icons.lock_reset_outlined, AppColors.warning, () {
Navigator.pop(context);
widget.onResetPassword!(m['id'] as String);
}, outlined: true),
if (widget.isSuperAdmin &&
widget.onAffecterOrganisation != null &&
(m['organisationId'] == null || (m['organisationId'] as String).isEmpty))
_actionBtn(context, 'Affecter à une organisation', Icons.business_outlined, ModuleColors.membres, () => _showAffecterDialog(context), outlined: true),
if (widget.onLifecycleAction != null)
..._lifecycleButtons(context, m),
// Suppression définitive — SuperAdmin uniquement, ET membre encore actif
if (widget.isSuperAdmin &&
widget.onDeleteAccount != null &&
(m['actif'] as bool? ?? true)) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
_actionBtn(
context,
'Supprimer ce compte',
Icons.delete_forever_outlined,
AppColors.error,
() => _confirmDeleteAccount(context, m),
outlined: true,
),
],
// Bannière "Compte désactivé" si le membre a été désactivé
if ((m['actif'] as bool? ?? true) == false) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(
Theme.of(context).brightness == Brightness.dark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.error.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.block_outlined, color: AppColors.error, size: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Compte désactivé',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.error),
),
Text(
'Ce compte a été supprimé et ne peut plus se connecter.',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondary),
),
],
),
),
],
),
),
],
],
),
),
),
],
),
);
}
// ── Helpers UI ─────────────────────────────────────────────────────────────
Widget _sectionCard(BuildContext context, String label, List<Widget> children) {
if (children.isEmpty) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant, width: 1),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(context, label),
const SizedBox(height: 10),
...children,
],
),
);
}
Widget _sectionLabel(BuildContext context, String text) {
return Text(
text.toUpperCase(),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: ModuleColors.membres, letterSpacing: 0.8),
);
}
Widget _detailRow(BuildContext context, IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: ModuleColors.membres),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant)),
const SizedBox(height: 2),
Text(value, style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
Widget _badgeChip(BuildContext context, IconData icon, Color color, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.35), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 5),
Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
],
),
);
}
Widget _kycBadge(BuildContext context, String statutKyc) {
final (Color color, IconData icon, String label) = switch (statutKyc) {
'VERIFIE' => (AppColors.success, Icons.verified_user_outlined, 'KYC Vérifié'),
'EN_COURS' => (AppColors.warning, Icons.pending_outlined, 'KYC En cours'),
'REJETE' => (AppColors.error, Icons.gpp_bad_outlined, 'KYC Rejeté'),
_ => (AppColors.textTertiary, Icons.help_outline, 'KYC Inconnu'),
};
return _badgeChip(context, icon, color, label);
}
Widget _detailStatusBadge(BuildContext context, String status) {
final (Color color, IconData icon) = switch (status) {
'Actif' => (AppColors.success, Icons.check_circle_outline),
'Inactif' => (AppColors.error, Icons.cancel_outlined),
'En attente' => (AppColors.warning, Icons.schedule_outlined),
'Suspendu' => (AppColors.textTertiary, Icons.block_outlined),
_ => (AppColors.textTertiary, Icons.help_outline),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.4), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 5),
Text(status, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: color)),
],
),
);
}
Widget _actionBtn(BuildContext context, String label, IconData icon, Color color, VoidCallback onPressed, {bool outlined = false}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SizedBox(
width: double.infinity,
child: outlined
? OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: OutlinedButton.styleFrom(
foregroundColor: color,
side: BorderSide(color: color),
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
)
: ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: AppColors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
);
}
List<Widget> _lifecycleButtons(BuildContext context, Map<String, dynamic> m) {
final memberId = m['id'] as String? ?? '';
final statut = m['statutMembre'] as String? ?? m['status'] as String? ?? '';
// Organisation du membre — utilisée pour les actions lifecycle.
// En contexte orgAdmin, c'est l'org fixe ; en superAdmin, on prend l'org du membre.
final orgId = m['organisationId'] as String? ?? '';
// Un orgAdmin ne peut pas agir sur un autre admin ou super-admin.
// Le superAdmin peut agir sur tout le monde — pas de restriction.
final roleCode = (m['roleCode'] as String? ?? '').toUpperCase();
final cibleEstAdmin = roleCode == 'ORGADMIN'
|| roleCode == 'SUPERADMIN'
|| roleCode == 'ADMIN_ORGANISATION'
|| roleCode == 'SUPER_ADMIN';
if (cibleEstAdmin && !widget.isSuperAdmin) return const [];
// Si on n'a pas d'org pour ce membre, impossible d'envoyer les actions lifecycle
if (orgId.isEmpty) return const [];
return [
if (statut == 'INVITE' || statut == 'EN_ATTENTE_VALIDATION' || statut == 'En attente')
_actionBtn(context, "Activer l'adhésion", Icons.check_circle_outline, AppColors.success, () {
Navigator.pop(context);
widget.onLifecycleAction!(memberId, orgId, 'activer', null);
}),
if (statut == 'ACTIF' || statut == 'Actif')
_actionBtn(context, "Suspendre l'adhésion", Icons.pause_circle_outline, AppColors.warning, () {
_showMotifDialog(context, "Suspendre l'adhésion",
onConfirm: (motif) => widget.onLifecycleAction!(memberId, orgId, 'suspendre', motif));
}, outlined: true),
if (statut == 'SUSPENDU' || statut == 'Suspendu')
_actionBtn(context, "Réactiver l'adhésion", Icons.play_circle_outline, AppColors.info, () {
Navigator.pop(context);
widget.onLifecycleAction!(memberId, orgId, 'activer', null);
}),
if (statut != 'RADIE' && statut != 'ARCHIVE' && statut.isNotEmpty)
_actionBtn(context, "Radier de l'organisation", Icons.block_outlined, AppColors.error, () {
_showMotifDialog(context, 'Radier le membre',
onConfirm: (motif) => widget.onLifecycleAction!(memberId, orgId, 'radier', motif));
}, outlined: true),
];
}
void _showMotifDialog(BuildContext context, String titre, {required void Function(String? motif) onConfirm}) {
final ctrl = TextEditingController();
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(titre),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(labelText: 'Motif (optionnel)', border: OutlineInputBorder()),
maxLines: 3,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary),
onPressed: () {
Navigator.pop(ctx);
Navigator.pop(context);
onConfirm(ctrl.text.isNotEmpty ? ctrl.text : null);
},
child: const Text('Confirmer'),
),
],
),
);
}
/// Confirmation de suppression définitive (SuperAdmin only).
/// Protection anti-clic — double confirmation + saisie du nom.
Future<void> _confirmDeleteAccount(BuildContext context, Map<String, dynamic> m) async {
final isDark = Theme.of(context).brightness == Brightness.dark;
final name = (m['name'] as String? ?? '').trim();
final memberId = m['id'] as String? ?? '';
final roleCode = (m['roleCode'] as String? ?? '').toUpperCase();
final isAdminTarget = roleCode == 'ORGADMIN' || roleCode == 'ADMIN_ORGANISATION';
final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
backgroundColor: isDark ? AppColors.surfaceDark : AppColors.surface,
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 22),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Supprimer ce compte ?',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: textPrimary),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vous êtes sur le point de supprimer le compte de :',
style: TextStyle(fontSize: 13, color: textSecondary),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(isDark ? 0.12 : 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.error.withOpacity(0.3)),
),
child: Row(
children: [
if (isAdminTarget) ...[
Icon(Icons.admin_panel_settings_outlined, size: 16, color: AppColors.error),
const SizedBox(width: 6),
],
Expanded(
child: Text(
name.isEmpty ? '(sans nom)' : name,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: textPrimary),
),
),
],
),
),
const SizedBox(height: 12),
Text(
isAdminTarget
? 'Ce membre est administrateur d\'organisation. Sa suppression désactivera son compte et suspendra toutes ses adhésions.'
: 'Le compte sera désactivé et toutes ses adhésions seront suspendues. Cette action est irréversible.',
style: TextStyle(fontSize: 12, color: textSecondary, height: 1.4),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text('Annuler', style: TextStyle(color: textSecondary, fontWeight: FontWeight.w600)),
),
ElevatedButton.icon(
onPressed: () => Navigator.of(ctx).pop(true),
icon: const Icon(Icons.delete_forever, size: 18),
label: const Text('Supprimer', style: TextStyle(fontWeight: FontWeight.w700)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: AppColors.onError,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
),
],
),
);
if (confirmed == true && context.mounted) {
// Fermer l'action sheet en plus du dialog
Navigator.of(context).pop();
widget.onDeleteAccount!(memberId);
}
}
Future<void> _showAffecterDialog(BuildContext context) async {
if (_orgs.isEmpty) {
try {
final repo = GetIt.instance<IOrganizationRepository>();
final orgs = await repo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_orgs = orgs.where((o) => o.id != null && o.id!.isNotEmpty).map((o) => {'id': o.id!, 'nom': o.nomAffichage}).toList();
widget.onOrganisationsLoaded(_orgs);
});
}
} catch (_) {}
}
if (!mounted) return;
String? selectedOrgId;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: const Text('Affecter à une organisation'),
content: _orgs.isEmpty
? const Text('Aucune organisation disponible.')
: DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Organisation'),
items: _orgs.map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!))).toList(),
onChanged: (v) => set(() => selectedOrgId = v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary),
onPressed: selectedOrgId == null
? null
: () {
Navigator.pop(ctx);
Navigator.pop(context);
widget.onAffecterOrganisation!(widget.member['id'] as String, selectedOrgId!);
},
child: const Text('Confirmer'),
),
],
),
),
);
}
String _fmt(dynamic date) {
if (date == null) return '';
if (date is DateTime) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
return date.toString();
}
}
/// Données d'une action swipe gauche pour les cartes membres
class _SwipeAction {
final String action; // 'activer' | 'suspendre' | 'radier'
final IconData icon;
final String label;
final Color color;
const _SwipeAction({
required this.action,
required this.icon,
required this.label,
required this.color,
});
}