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
This commit is contained in:
dahoud
2026-04-07 20:56:03 +00:00
parent 22f9c7e9a1
commit 70cbd1c873
63 changed files with 9316 additions and 6122 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
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 {
@@ -14,6 +16,16 @@ class MembersPageWithDataAndPagination extends StatefulWidget {
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,
@@ -25,6 +37,11 @@ class MembersPageWithDataAndPagination extends StatefulWidget {
required this.onRefresh,
this.onSearch,
this.onAddMember,
this.organisationId,
this.onActivateMember,
this.onResetPassword,
this.onAffecterOrganisation,
this.onLifecycleAction,
});
@override
@@ -37,6 +54,11 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
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();
@@ -74,51 +96,55 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
);
}
// ── Header ────────────────────────────────────────────────────────────────
Widget _buildHeader() {
final activeCount = widget.members.where((m) => m['status'] == 'Actif').length;
final pendingCount = widget.members.where((m) => m['status'] == 'En attente').length;
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.all(12),
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),
),
border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
),
child: Row(
children: [
Expanded(child: _buildStatBadge('Total', widget.totalCount.toString(), UnionFlowColors.unionGreen)),
const SizedBox(width: 12),
Expanded(child: _buildStatBadge('Actifs', activeCount.toString(), UnionFlowColors.success)),
const SizedBox(width: 12),
Expanded(child: _buildStatBadge('Attente', pendingCount.toString(), UnionFlowColors.warning)),
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) {
Widget _buildStatBadge(String label, String value, Color color, {String? subtitle}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3), width: 1),
border: Border.all(color: color.withOpacity(0.25), width: 1),
),
child: Column(
children: [
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)),
const SizedBox(height: 2),
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.all(12),
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)),
@@ -136,7 +162,7 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
},
style: const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
decoration: InputDecoration(
hintText: 'Rechercher un membre...',
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
@@ -150,24 +176,15 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
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),
),
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: 12),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -191,11 +208,12 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
final isSelected = _filterStatus == label;
return GestureDetector(
onTap: () => setState(() => _filterStatus = label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.surface,
borderRadius: BorderRadius.circular(12),
color: isSelected ? UnionFlowColors.unionGreen : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.border, width: 1),
),
child: Text(
@@ -210,11 +228,14 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
);
}
// ── Liste ─────────────────────────────────────────────────────────────────
Widget _buildMembersList() {
final filtered = widget.members.where((m) {
final matchesSearch = _searchQuery.isEmpty ||
m['name']!.toLowerCase().contains(_searchQuery.toLowerCase()) ||
(m['email']?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false);
(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();
@@ -225,7 +246,7 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
onRefresh: () async => widget.onRefresh(),
color: UnionFlowColors.unionGreen,
child: ListView.separated(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, index) => _buildMemberCard(filtered[index]),
@@ -233,11 +254,17 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
);
}
// ── 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: 10, vertical: 7),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(10),
@@ -245,104 +272,183 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
),
child: Row(
children: [
// Avatar
Container(
width: 32,
height: 32,
width: 38,
height: 38,
decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
member['initiales'] ?? '??',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
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: [
Text(
member['name'] ?? 'Inconnu',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: UnionFlowColors.textPrimary),
// 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'] ?? 'Membre',
member['role'] as String? ?? 'Membre',
style: const TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary),
),
if (member['email'] != null) ...[
const SizedBox(height: 4),
// Organisation (SUPER_ADMIN uniquement)
if (_isSuperAdmin && orgName != null && orgName.isNotEmpty) ...[
const SizedBox(height: 3),
Row(
children: [
const Icon(Icons.email_outlined, size: 12, color: UnionFlowColors.textTertiary),
const Icon(Icons.business_outlined, size: 11, color: UnionFlowColors.unionGreen),
const SizedBox(width: 4),
Text(member['email']!, style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary)),
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,
),
),
],
),
],
],
),
),
_buildStatusBadge(member['status']),
const SizedBox(width: 8),
_buildStatusBadge(status),
],
),
),
);
}
Widget _buildStatusBadge(String? status) {
Color color;
switch (status) {
case 'Actif':
color = UnionFlowColors.success;
break;
case 'Inactif':
color = UnionFlowColors.error;
break;
case 'En attente':
color = UnionFlowColors.warning;
break;
default:
color = UnionFlowColors.textSecondary;
}
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: 10, vertical: 4),
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: Text(status ?? '?', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(color: UnionFlowColors.unionGreenPale, shape: BoxShape.circle),
child: const Icon(Icons.people_outline, size: 40, color: UnionFlowColors.unionGreen),
),
const SizedBox(height: 12),
const Text(
'Aucun membre trouvé',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary),
),
const SizedBox(height: 8),
Text(
_searchQuery.isEmpty ? 'Changez vos filtres' : 'Essayez une autre recherche',
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
),
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: 12),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
border: Border(top: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
@@ -354,10 +460,7 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
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,
)
? () => widget.onPageChanged(widget.currentPage - 1, _searchQuery.isEmpty ? null : _searchQuery)
: null,
),
Container(
@@ -372,10 +475,7 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
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,
)
? () => widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery)
: null,
),
],
@@ -383,61 +483,434 @@ class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAn
);
}
// ── Sheet détail membre ───────────────────────────────────────────────────
void _showMemberDetails(Map<String, dynamic> member) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
member['initiales'] ?? '??',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22),
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),
),
const SizedBox(height: 10),
Text(
member['name'] ?? '',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary),
),
// 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),
),
const SizedBox(height: 4),
Text(
member['role'] ?? '',
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
),
],
);
}
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'),
),
const SizedBox(height: 12),
_buildInfoRow(Icons.email_outlined, member['email'] ?? 'Non fourni'),
_buildInfoRow(Icons.phone_outlined, member['phone'] ?? 'Non fourni'),
_buildInfoRow(Icons.location_on_outlined, member['location'] ?? 'Non renseigné'),
_buildInfoRow(Icons.work_outline, member['department'] ?? 'Aucun département'),
const SizedBox(height: 12),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String text) {
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
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: 18, color: UnionFlowColors.unionGreen),
const SizedBox(width: 12),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary))),
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();
}
}

View File

@@ -15,7 +15,7 @@ import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
import '../../bloc/membres_state.dart';
import '../../data/models/membre_complete_model.dart';
import '../widgets/add_member_dialog.dart';
import '../widgets/add_member_dialog.dart' show showAddMemberSheet, showCredentialsDialog;
import 'members_page_connected.dart';
final _getIt = GetIt.instance;
@@ -55,55 +55,14 @@ class MembersPageConnected extends StatelessWidget {
Widget build(BuildContext context) {
return BlocListener<MembresBloc, MembresState>(
listener: (context, state) {
// Après création : afficher le mot de passe temporaire si disponible, puis recharger
// Après création : recharger la liste (la dialog mot de passe est gérée dans AddMemberDialog)
if (state is MembreCreated) {
final motDePasse = state.membre.motDePasseTemporaire;
if (motDePasse != null && motDePasse.isNotEmpty) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text('Compte créé avec succès'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Le membre ${state.membre.nomComplet} a été créé.'),
const SizedBox(height: 12),
const Text(
'Mot de passe temporaire :',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SelectableText(
motDePasse,
style: const TextStyle(
fontSize: 18,
fontFamily: 'monospace',
letterSpacing: 2,
),
),
const SizedBox(height: 12),
const Text(
'Communiquez ce mot de passe au membre. Il devra le changer à sa première connexion.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(_).pop(),
child: const Text('OK'),
),
],
),
);
}
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
// Gestion des erreurs avec SnackBar
if (state is MembresError) {
final bloc = context.read<MembresBloc>();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
@@ -113,12 +72,67 @@ class MembersPageConnected extends StatelessWidget {
label: 'Réessayer',
textColor: Colors.white,
onPressed: () {
context.read<MembresBloc>().add(LoadMembres(organisationId: organisationId));
bloc.add(LoadMembres(organisationId: organisationId));
},
),
),
);
}
// Après activation : succès + rechargement
if (state is MembreActivated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre activé avec succès'),
backgroundColor: Colors.green,
duration: Duration(seconds: 3),
),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
// Après reset mot de passe : afficher le dialog credentials
if (state is MotDePasseReinitialise) {
showCredentialsDialog(context, state.membre);
}
// Après affectation à une organisation : succès + rechargement
if (state is MembreAffecte) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre affecté à l\'organisation avec succès'),
backgroundColor: Colors.green,
duration: Duration(seconds: 3),
),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
// Lifecycle adhésion : succès + rechargement
if (state is MembreInvite) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invitation envoyée'), backgroundColor: Colors.blue, duration: Duration(seconds: 3)),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
if (state is AdhesionActivee) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Adhésion activée'), backgroundColor: Colors.green, duration: Duration(seconds: 3)),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
if (state is AdhesionSuspendue) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: Colors.orange, duration: Duration(seconds: 3)),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
if (state is MembreRadie) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: Colors.red, duration: Duration(seconds: 3)),
);
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
},
child: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
@@ -178,6 +192,7 @@ class MembersPageConnected extends StatelessWidget {
totalCount: state.totalElements,
currentPage: state.currentPage,
totalPages: state.totalPages,
organisationId: organisationId,
onPageChanged: (newPage, recherche) {
AppLogger.userAction('Load page', data: {'page': newPage});
context.read<MembresBloc>().add(LoadMembres(page: newPage, recherche: recherche, organisationId: organisationId));
@@ -189,16 +204,37 @@ class MembersPageConnected extends StatelessWidget {
onSearch: (query) {
context.read<MembresBloc>().add(LoadMembres(page: 0, recherche: query, organisationId: organisationId));
},
onAddMember: () async {
final bloc = context.read<MembresBloc>();
await showDialog<void>(
context: context,
builder: (_) => BlocProvider.value(
value: bloc,
child: const AddMemberDialog(),
),
);
onAddMember: () => showAddMemberSheet(context),
onActivateMember: (memberId) {
context.read<MembresBloc>().add(ActivateMembre(memberId));
},
onResetPassword: (memberId) {
context.read<MembresBloc>().add(ResetMotDePasse(memberId));
},
onAffecterOrganisation: organisationId == null
? (memberId, orgId) {
context.read<MembresBloc>().add(AffecterOrganisation(memberId, orgId));
}
: null,
onLifecycleAction: organisationId != null
? (memberId, action, motif) {
final bloc = context.read<MembresBloc>();
switch (action) {
case 'inviter':
bloc.add(InviterMembre(membreId: memberId, organisationId: organisationId!));
break;
case 'activer':
bloc.add(ActiverAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
case 'suspendre':
bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
case 'radier':
bloc.add(RadierAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
}
}
: null,
);
}