Files
unionflow-mobile-apps/lib/features/members/presentation/pages/members_page_connected.dart
dahoud 36a903c80e feat(members): swipe par rôle + suppression SuperAdmin avec cascade UX
Swipe actions différenciées par rôle :
- SuperAdmin : → Reset MDP, ← Affecter Org
- OrgAdmin  : → Reset MDP, ← lifecycle selon statut (Suspendre/Activer/Réactiver)
  (masqué si cible = ORGADMIN/SUPERADMIN — cohérent avec guard backend)
- Autres rôles : → Reset MDP seulement

Suppression compte (SuperAdmin uniquement) :
- Nouveau callback onDeleteAccount dans MembersPage + MemberDetailPage
- Bouton rouge 'Supprimer ce compte' dans action sheet (zone destructive)
- Dialog de confirmation adaptatif dark/light avec badge admin si cible ORGADMIN
- Bouton caché si compte déjà désactivé (actif=false)
- Bannière 'Compte désactivé' visible sur page détail d'un compte soft-deleted
- BlocListener MembreDeleted : SnackBar + maybePop() + reload liste
- Bloc gère 409 Conflict (mono-admin) → MembresActionForbidden avec message backend

Nouvelles signatures :
- onLifecycleAction : (memberId, organisationId, action, motif) — inclut orgId
  pour permettre au SuperAdmin d'agir via l'org du membre lui-même
- 'actif' et 'roleCode' exposés dans la map via _convertMembreToMap
2026-04-15 20:14:08 +00:00

1761 lines
72 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';
Timer? _searchDebounce;
bool _isSearchExpanded = false;
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();
});
}
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;
return matchesSearch && matchesStatus;
}).toList();
}
// ═══════════════════════════════════════════════════════════════════════════
// Build
// ═══════════════════════════════════════════════════════════════════════════
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildMainAppBar(),
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),
),
Expanded(child: _buildContent(context)),
],
),
);
}
// ─── 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),
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',
),
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);
// ─── Panneau recherche collapsible ────────────────────────────────────────
Widget _buildSearchPanel(BuildContext context) {
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,
children: [
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),
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),
))
.toList(),
),
),
],
),
);
}
Widget _buildFilterChip(BuildContext context, String label) {
final isSelected = _filterStatus == label;
return GestureDetector(
onTap: () => setState(() => _filterStatus = label),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? ModuleColors.membres : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? ModuleColors.membres : 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' || _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'; _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,
});
}