Files
unionflow-mobile-apps/lib/features/members/presentation/pages/members_page_connected.dart
dahoud 70cbd1c873 fix(mobile): URL changement mdp corrigée + v3.0 — multi-org, AppAuth, sécurité prod
Auth:
- profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password

Multi-org (Phase 3):
- OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry
- org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role

Navigation:
- MorePage: navigation conditionnelle par typeOrganisation
- Suppression adaptive_navigation (remplacé par main_navigation_layout)

Auth AppAuth:
- keycloak_webview_auth_service: fixes AppAuth Android
- AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet

Onboarding:
- Nouveaux états: payment_method_page, onboarding_shared_widgets
- SouscriptionStatusModel mis à jour StatutValidationSouscription

Android:
- build.gradle: ProGuard/R8, network_security_config
- Gradle wrapper mis à jour
2026-04-07 20:56:03 +00:00

917 lines
39 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../../shared/design_system/unionflow_design_v2.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
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;
/// Callback déclenché quand l'admin active un membre en attente
final void Function(String memberId)? onActivateMember;
/// Callback déclenché quand l'admin réinitialise le mot de passe d'un membre
final void Function(String memberId)? onResetPassword;
/// Callback déclenché quand le superadmin affecte un membre à une organisation
final void Function(String memberId, String organisationId)? onAffecterOrganisation;
/// Callback pour les actions de cycle de vie adhésion (admin org)
final void Function(String memberId, String action, String? motif)? onLifecycleAction;
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,
});
@override
State<MembersPageWithDataAndPagination> createState() => _MembersPageWithDataAndPaginationState();
}
class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAndPagination> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
String _filterStatus = 'Tous';
Timer? _searchDebounce;
// Organisations pour le picker d'affectation (superadmin)
List<Map<String, String>> _organisationsPicker = [];
bool get _isSuperAdmin => widget.organisationId == null;
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: UnionFlowColors.background,
appBar: UFAppBar(
title: 'Annuaire Membres',
backgroundColor: UnionFlowColors.surface,
foregroundColor: UnionFlowColors.textPrimary,
actions: [
if (widget.onAddMember != null)
IconButton(
icon: const Icon(Icons.person_add_outlined),
color: UnionFlowColors.unionGreen,
onPressed: widget.onAddMember,
tooltip: 'Ajouter un membre',
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
_buildHeader(),
_buildSearchAndFilters(),
Expanded(child: _buildMembersList()),
if (widget.totalPages > 1) _buildPagination(),
],
),
);
}
// ── Header ────────────────────────────────────────────────────────────────
Widget _buildHeader() {
final pageMembers = widget.members;
final activeCount = pageMembers.where((m) => m['status'] == 'Actif').length;
final pendingCount = pageMembers.where((m) => m['status'] == 'En attente').length;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
),
child: Row(
children: [
Expanded(child: _buildStatBadge('Total', widget.totalCount.toString(), UnionFlowColors.unionGreen, subtitle: 'global')),
const SizedBox(width: 8),
Expanded(child: _buildStatBadge('Actifs', activeCount.toString(), UnionFlowColors.success, subtitle: 'cette page')),
const SizedBox(width: 8),
Expanded(child: _buildStatBadge('Attente', pendingCount.toString(), UnionFlowColors.warning, subtitle: 'cette page')),
],
),
);
}
Widget _buildStatBadge(String label, String value, Color color, {String? subtitle}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.25), width: 1),
),
child: Column(
children: [
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: color)),
Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)),
if (subtitle != null)
Text(subtitle, style: TextStyle(fontSize: 9, color: color.withOpacity(0.6))),
],
),
);
}
// ── Recherche + Filtres ────────────────────────────────────────────────────
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
),
child: Column(
children: [
TextField(
controller: _searchController,
onChanged: (v) {
setState(() => _searchQuery = v);
_searchDebounce?.cancel();
_searchDebounce = Timer(AppConstants.searchDebounce, () {
widget.onSearch?.call(v.isEmpty ? null : v);
});
},
style: const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
decoration: InputDecoration(
hintText: 'Nom, email, numéro membre...',
hintStyle: const TextStyle(fontSize: 13, color: UnionFlowColors.textTertiary),
prefixIcon: const Icon(Icons.search, size: 20, color: UnionFlowColors.textSecondary),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18, color: UnionFlowColors.textSecondary),
onPressed: () {
_searchDebounce?.cancel();
_searchController.clear();
setState(() => _searchQuery = '');
widget.onSearch?.call(null);
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 11, horizontal: 14),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.3))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.3))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5)),
filled: true,
fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3),
),
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('Tous'),
const SizedBox(width: 8),
_buildFilterChip('Actif'),
const SizedBox(width: 8),
_buildFilterChip('Inactif'),
const SizedBox(width: 8),
_buildFilterChip('En attente'),
],
),
),
],
),
);
}
Widget _buildFilterChip(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 ? UnionFlowColors.unionGreen : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.border, width: 1),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : UnionFlowColors.textSecondary,
),
),
),
);
}
// ── Liste ─────────────────────────────────────────────────────────────────
Widget _buildMembersList() {
final filtered = widget.members.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();
if (filtered.isEmpty) return _buildEmptyState();
return RefreshIndicator(
onRefresh: () async => widget.onRefresh(),
color: UnionFlowColors.unionGreen,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, index) => _buildMemberCard(filtered[index]),
),
);
}
// ── Carte membre ──────────────────────────────────────────────────────────
Widget _buildMemberCard(Map<String, dynamic> member) {
final String? orgName = member['organisationNom'] as String?;
final String? numero = member['numeroMembre'] as String?;
final String status = member['status'] as String? ?? '?';
return GestureDetector(
onTap: () => _showMemberDetails(member),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.border.withOpacity(0.3), width: 1),
),
child: Row(
children: [
// Avatar
Container(
width: 38,
height: 38,
decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
member['initiales'] as String? ?? '??',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14),
),
),
const SizedBox(width: 12),
// Infos principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom + numéro
Row(
children: [
Expanded(
child: Text(
member['name'] as String? ?? 'Inconnu',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: UnionFlowColors.textPrimary),
overflow: TextOverflow.ellipsis,
),
),
if (numero != null && numero.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
numero,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: UnionFlowColors.textTertiary),
),
],
],
),
const SizedBox(height: 2),
// Rôle
Text(
member['role'] as String? ?? 'Membre',
style: const TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary),
),
// Organisation (SUPER_ADMIN uniquement)
if (_isSuperAdmin && orgName != null && orgName.isNotEmpty) ...[
const SizedBox(height: 3),
Row(
children: [
const Icon(Icons.business_outlined, size: 11, color: UnionFlowColors.unionGreen),
const SizedBox(width: 4),
Expanded(
child: Text(
orgName,
style: const TextStyle(fontSize: 11, color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
// Email
if (member['email'] != null) ...[
const SizedBox(height: 3),
Row(
children: [
const Icon(Icons.email_outlined, size: 11, color: UnionFlowColors.textTertiary),
const SizedBox(width: 4),
Expanded(
child: Text(
member['email'] as String,
style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
_buildStatusBadge(status),
],
),
),
);
}
Widget _buildStatusBadge(String status) {
final (Color color, IconData icon) = switch (status) {
'Actif' => (UnionFlowColors.success, Icons.check_circle_outline),
'Inactif' => (UnionFlowColors.error, Icons.cancel_outlined),
'En attente' => (UnionFlowColors.warning, Icons.schedule_outlined),
'Suspendu' => (const Color(0xFF9E9E9E), Icons.block_outlined),
_ => (UnionFlowColors.textSecondary, Icons.help_outline),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 11, color: color),
const SizedBox(width: 4),
Text(status, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
],
),
);
}
// ── État vide ─────────────────────────────────────────────────────────────
Widget _buildEmptyState() {
final hasActiveFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: const BoxDecoration(color: UnionFlowColors.unionGreenPale, shape: BoxShape.circle),
child: Icon(
hasActiveFilters ? Icons.filter_list_off : Icons.people_outline,
size: 40,
color: UnionFlowColors.unionGreen,
),
),
const SizedBox(height: 16),
Text(
hasActiveFilters ? 'Aucun résultat' : 'Aucun membre',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary),
),
const SizedBox(height: 8),
Text(
hasActiveFilters
? 'Modifiez la recherche ou le filtre de statut'
: 'Aucun membre enregistré pour le moment',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
),
if (hasActiveFilters) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: () {
setState(() {
_filterStatus = 'Tous';
_searchQuery = '';
_searchController.clear();
});
widget.onSearch?.call(null);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: UnionFlowColors.unionGreen),
borderRadius: BorderRadius.circular(20),
),
child: const Text('Effacer les filtres', style: TextStyle(fontSize: 13, color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w600)),
),
),
],
],
),
),
);
}
// ── Pagination ────────────────────────────────────────────────────────────
Widget _buildPagination() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
border: Border(top: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 24),
color: widget.currentPage > 0 ? UnionFlowColors.unionGreen : UnionFlowColors.textTertiary,
onPressed: widget.currentPage > 0
? () => widget.onPageChanged(widget.currentPage - 1, _searchQuery.isEmpty ? null : _searchQuery)
: null,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(gradient: UnionFlowColors.primaryGradient, borderRadius: BorderRadius.circular(20)),
child: Text(
'Page ${widget.currentPage + 1} / ${widget.totalPages}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white),
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 24),
color: widget.currentPage < widget.totalPages - 1 ? UnionFlowColors.unionGreen : UnionFlowColors.textTertiary,
onPressed: widget.currentPage < widget.totalPages - 1
? () => widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery)
: null,
),
],
),
);
}
// ── Sheet détail membre ───────────────────────────────────────────────────
void _showMemberDetails(Map<String, dynamic> member) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.55,
minChildSize: 0.4,
maxChildSize: 0.85,
expand: false,
builder: (context, scrollController) => Container(
decoration: const BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Drag handle
Container(
margin: const EdgeInsets.only(top: 10),
width: 36,
height: 4,
decoration: BoxDecoration(color: UnionFlowColors.border, borderRadius: BorderRadius.circular(2)),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avatar + nom + statut
Row(
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
member['initiales'] as String? ?? '??',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
member['name'] as String? ?? '',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary),
),
const SizedBox(height: 3),
Text(
member['role'] as String? ?? 'Membre',
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
),
if (member['numeroMembre'] != null) ...[
const SizedBox(height: 3),
Text(
member['numeroMembre'] as String,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: UnionFlowColors.textTertiary),
),
],
],
),
),
_buildStatusBadge(member['status'] as String? ?? '?'),
],
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
// Infos contact
_buildSectionTitle('Contact'),
_buildDetailRow(Icons.email_outlined, 'Email', member['email'] as String? ?? ''),
if ((member['phone'] as String? ?? '').isNotEmpty)
_buildDetailRow(Icons.phone_outlined, 'Téléphone', member['phone'] as String),
const SizedBox(height: 12),
// Infos organisation
if (member['organisationNom'] != null || member['organisationId'] != null) ...[
_buildSectionTitle('Organisation'),
if (member['organisationNom'] != null)
_buildDetailRow(Icons.business_outlined, 'Organisation', member['organisationNom'] as String),
if (member['dateAdhesion'] != null)
_buildDetailRow(Icons.calendar_today_outlined, 'Adhésion', _formatDate(member['dateAdhesion'])),
const SizedBox(height: 12),
],
// Infos pro
if ((member['department'] as String? ?? '').isNotEmpty) ...[
_buildSectionTitle('Profil'),
_buildDetailRow(Icons.work_outline, 'Profession', member['department'] as String),
if ((member['nationalite'] as String? ?? '').isNotEmpty)
_buildDetailRow(Icons.flag_outlined, 'Nationalité', member['nationalite'] as String),
if ((member['location'] as String? ?? ', ').trim() != ',')
_buildDetailRow(Icons.location_on_outlined, 'Localisation', member['location'] as String),
],
// Bouton d'activation (ADMIN_ORGANISATION uniquement, membre en attente)
if (member['status'] == 'En attente' && widget.onActivateMember != null) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
widget.onActivateMember!(member['id'] as String);
},
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text('Activer le membre', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.success,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
// Bouton reset mot de passe (tous membres avec compte Keycloak)
if (widget.onResetPassword != null) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
widget.onResetPassword!(member['id'] as String);
},
icon: const Icon(Icons.lock_reset_outlined, size: 18),
label: const Text('Réinitialiser le mot de passe', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: OutlinedButton.styleFrom(
foregroundColor: UnionFlowColors.warning,
side: const BorderSide(color: UnionFlowColors.warning),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
// Bouton affectation organisation (superadmin, membre sans organisation)
if (_isSuperAdmin &&
widget.onAffecterOrganisation != null &&
(member['organisationId'] == null || (member['organisationId'] as String).isEmpty)) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _showAffecterOrganisationDialog(context, member),
icon: const Icon(Icons.business_outlined, size: 18),
label: const Text('Affecter à une organisation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: OutlinedButton.styleFrom(
foregroundColor: UnionFlowColors.unionGreen,
side: const BorderSide(color: UnionFlowColors.unionGreen),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
// ── Boutons cycle de vie adhésion (ADMIN_ORGANISATION) ──
if (!_isSuperAdmin && widget.onLifecycleAction != null) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 8),
_buildLifecycleActionsSection(context, member),
],
],
),
),
),
],
),
),
),
);
}
Widget _buildLifecycleActionsSection(BuildContext context, Map<String, dynamic> member) {
final memberId = member['id'] as String? ?? '';
final statut = member['statutMembre'] as String? ?? member['status'] as String? ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('Actions adhésion', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.grey)),
),
// Activer (INVITE ou EN_ATTENTE_VALIDATION)
if (statut == 'INVITE' || statut == 'EN_ATTENTE_VALIDATION' || statut == 'En attente')
_lifecycleButton(
context: context,
label: 'Activer l\'adhésion',
icon: Icons.check_circle_outline,
color: Colors.green,
onPressed: () {
Navigator.pop(context);
widget.onLifecycleAction!(memberId, 'activer', null);
},
),
// Suspendre (ACTIF)
if (statut == 'ACTIF' || statut == 'Actif')
_lifecycleButton(
context: context,
label: 'Suspendre l\'adhésion',
icon: Icons.pause_circle_outline,
color: Colors.orange,
outlined: true,
onPressed: () => _showMotifDialog(
context,
'Suspendre l\'adhésion',
onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'suspendre', motif),
),
),
// Réactiver (SUSPENDU)
if (statut == 'SUSPENDU' || statut == 'Suspendu')
_lifecycleButton(
context: context,
label: 'Réactiver l\'adhésion',
icon: Icons.play_circle_outline,
color: Colors.blue,
onPressed: () {
Navigator.pop(context);
widget.onLifecycleAction!(memberId, 'activer', null);
},
),
// Radier (tout statut actif)
if (statut != 'RADIE' && statut != 'ARCHIVE' && statut.isNotEmpty)
_lifecycleButton(
context: context,
label: 'Radier de l\'organisation',
icon: Icons.block_outlined,
color: Colors.red,
outlined: true,
onPressed: () => _showMotifDialog(
context,
'Radier le membre',
onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'radier', motif),
),
),
],
);
}
Widget _lifecycleButton({
required BuildContext context,
required String label,
required IconData icon,
required Color color,
bool outlined = false,
required VoidCallback onPressed,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
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: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
)
: 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: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
);
}
void _showMotifDialog(
BuildContext context,
String titre, {
required void Function(String? motif) onConfirm,
}) {
final motifCtrl = TextEditingController();
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(titre),
content: TextField(
controller: motifCtrl,
decoration: const InputDecoration(
labelText: 'Motif (optionnel)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx); // ferme dialog motif
Navigator.pop(context); // ferme bottom sheet
onConfirm(motifCtrl.text.isNotEmpty ? motifCtrl.text : null);
},
child: const Text('Confirmer'),
),
],
),
);
}
Future<void> _showAffecterOrganisationDialog(
BuildContext ctx,
Map<String, dynamic> member,
) async {
// Charger les organisations si pas encore fait
if (_organisationsPicker.isEmpty) {
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 (_) {}
}
if (!mounted) return;
String? selectedOrgId;
await showDialog<void>(
context: ctx,
builder: (dialogCtx) => StatefulBuilder(
builder: (dialogCtx, setDialogState) => 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<String>(
value: o['id'],
child: Text(o['nom']!),
))
.toList(),
onChanged: (v) => setDialogState(() => selectedOrgId = v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: selectedOrgId == null
? null
: () {
Navigator.pop(dialogCtx);
Navigator.pop(ctx); // ferme le bottom sheet
widget.onAffecterOrganisation!(
member['id'] as String,
selectedOrgId!,
);
},
child: const Text('Confirmer'),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
title.toUpperCase(),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: UnionFlowColors.textTertiary, letterSpacing: 0.8),
),
);
}
Widget _buildDetailRow(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: UnionFlowColors.unionGreen),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary)),
const SizedBox(height: 1),
Text(value, style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary, fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
String _formatDate(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();
}
}