Files
unionflow-mobile-apps/lib/features/members/presentation/widgets/add_member_dialog.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

1301 lines
48 KiB
Dart

/// Bottom sheet d'ajout de membre — Design UnionFlow V2
library add_member_dialog;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../authentication/data/models/user_role.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
import '../../bloc/membres_state.dart';
import '../../data/models/membre_complete_model.dart';
/// Ouvre le bottom sheet d'ajout de membre.
/// Doit être appelé depuis un contexte qui a accès à [MembresBloc] et [AuthBloc].
void showAddMemberSheet(BuildContext context) {
final membresBloc = context.read<MembresBloc>();
final authState = context.read<AuthBloc>().state;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => BlocProvider.value(
value: membresBloc,
child: AddMemberSheet(
authState: authState,
onCreated: (membre) => _showPasswordDialog(context, membre),
),
),
);
}
// ─── Helpers credentials ─────────────────────────────────────────────────────
/// Formate le bloc de credentials en texte brut partageable
String _credentialText(String nomComplet, String email, String password) =>
'''
Identifiants UnionFlow
─────────────────────────────
Membre $nomComplet
Email $email
Mot de passe $password
─────────────────────────────
⚠ Changez le mot de passe à la première connexion.
'''
.trim();
// ─── Dialog credentials ───────────────────────────────────────────────────────
/// Point d'entrée public — utilisable depuis n'importe quel widget (ex: reset mot de passe)
void showCredentialsDialog(BuildContext context, MembreCompletModel membre) =>
_showPasswordDialog(context, membre);
void _showPasswordDialog(BuildContext context, MembreCompletModel membre) {
final username = membre.email;
final password = membre.motDePasseTemporaire;
final nomComplet = '${membre.prenom} ${membre.nom}';
final credText = password != null
? _credentialText(nomComplet, username, password)
: null;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogCtx) => _CredentialsDialog(
membre: membre,
username: username,
password: password,
credText: credText,
),
);
}
// ─── Widget dialog ────────────────────────────────────────────────────────────
class _CredentialsDialog extends StatefulWidget {
final MembreCompletModel membre;
final String username;
final String? password;
final String? credText;
const _CredentialsDialog({
required this.membre,
required this.username,
this.password,
this.credText,
});
@override
State<_CredentialsDialog> createState() => _CredentialsDialogState();
}
class _CredentialsDialogState extends State<_CredentialsDialog> {
bool _copied = false;
void _copyAll() {
if (widget.credText == null) return;
Clipboard.setData(ClipboardData(text: widget.credText!));
setState(() => _copied = true);
Future.delayed(const Duration(seconds: 2),
() { if (mounted) setState(() => _copied = false); });
}
Future<void> _shareViaSms() async {
if (widget.credText == null) return;
await Share.share(widget.credText!, subject: 'Identifiants UnionFlow');
}
Future<void> _shareViaEmail() async {
if (widget.credText == null) return;
final subject = Uri.encodeComponent('Vos identifiants UnionFlow');
final body = Uri.encodeComponent(widget.credText!);
final uri = Uri.parse('mailto:${widget.membre.email}?subject=$subject&body=$body');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
@override
Widget build(BuildContext context) {
final nomComplet = '${widget.membre.prenom} ${widget.membre.nom}';
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header ──────────────────────────────────────────────
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: UnionFlowColors.successPale,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.check_circle_rounded,
color: UnionFlowColors.success, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Compte créé',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary)),
Text(nomComplet,
style: const TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary)),
],
),
),
],
),
const SizedBox(height: 16),
// ── Cas sans mot de passe ────────────────────────────────
if (widget.password == null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UnionFlowColors.infoPale,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.infoLight),
),
child: const Row(
children: [
Icon(Icons.info_outline_rounded,
size: 15, color: UnionFlowColors.info),
SizedBox(width: 8),
Expanded(
child: Text(
'Provisionnement Keycloak non bloquant — mot de passe à définir manuellement via la console.',
style: TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary,
height: 1.4),
),
),
],
),
),
],
// ── Bloc credentials ─────────────────────────────────────
if (widget.password != null) ...[
const Text(
'IDENTIFIANTS À COMMUNIQUER',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textTertiary,
letterSpacing: 1),
),
const SizedBox(height: 8),
// Bloc code sombre
Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF1E2A1E), // vert ardoise sombre
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2E4A2E)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre titre du bloc
Container(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
decoration: const BoxDecoration(
color: Color(0xFF162216),
borderRadius:
BorderRadius.vertical(top: Radius.circular(11)),
border: Border(
bottom: BorderSide(color: Color(0xFF2E4A2E))),
),
child: Row(
children: [
const Icon(Icons.terminal_rounded,
size: 13, color: Color(0xFF4CAF50)),
const SizedBox(width: 6),
const Text(
'credentials.txt',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF90C890),
fontFamily: 'monospace'),
),
const Spacer(),
GestureDetector(
onTap: _copyAll,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _copied
? const Row(
key: ValueKey('copied'),
children: [
Icon(Icons.check_rounded,
size: 13,
color: Color(0xFF4CAF50)),
SizedBox(width: 4),
Text('Copié',
style: TextStyle(
fontSize: 11,
color: Color(0xFF4CAF50),
fontFamily: 'monospace')),
],
)
: const Row(
key: ValueKey('copy'),
children: [
Icon(Icons.copy_rounded,
size: 13,
color: Color(0xFF90C890)),
SizedBox(width: 4),
Text('Copier tout',
style: TextStyle(
fontSize: 11,
color: Color(0xFF90C890),
fontFamily: 'monospace')),
],
),
),
),
],
),
),
// Corps du bloc
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_credLine('# Identifiants UnionFlow',
color: const Color(0xFF6A9955),
isComment: true),
const SizedBox(height: 8),
_credLine('email ',
value: widget.username,
labelColor: const Color(0xFF9CDCFE),
valueColor: const Color(0xFFCE9178)),
const SizedBox(height: 6),
_credLine('mot_de_passe',
value: widget.password!,
labelColor: const Color(0xFF9CDCFE),
valueColor: const Color(0xFFD4D4D4),
isPassword: true),
const SizedBox(height: 12),
_credLine(
'# Changez le mot de passe à la 1ère connexion',
color: const Color(0xFF6A9955),
isComment: true),
],
),
),
],
),
),
const SizedBox(height: 16),
// ── Boutons partage ──────────────────────────────────
const Text(
'PARTAGER LES IDENTIFIANTS',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textTertiary,
letterSpacing: 1),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _shareButton(
icon: Icons.sms_rounded,
label: 'SMS',
color: UnionFlowColors.unionGreen,
onTap: _shareViaSms,
),
),
const SizedBox(width: 10),
Expanded(
child: _shareButton(
icon: Icons.email_rounded,
label: 'Email',
color: UnionFlowColors.info,
onTap: _shareViaEmail,
),
),
const SizedBox(width: 10),
Expanded(
child: _shareButton(
icon: Icons.share_rounded,
label: 'Partager',
color: UnionFlowColors.amber,
onTap: _shareViaSms, // opens share sheet
),
),
],
),
const SizedBox(height: 14),
// ── Note prochaine étape ─────────────────────────────
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.warningPale,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.warningLight),
),
child: const Row(
children: [
Icon(Icons.info_outline_rounded,
size: 14, color: UnionFlowColors.warning),
SizedBox(width: 8),
Expanded(
child: Text(
'Pensez à valider l\'adhésion du membre pour activer son accès complet.',
style: TextStyle(
fontSize: 11,
color: UnionFlowColors.textSecondary,
height: 1.4),
),
),
],
),
),
],
const SizedBox(height: 20),
// ── Action ───────────────────────────────────────────────
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
style: FilledButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
child: const Text('Compris',
style: TextStyle(fontWeight: FontWeight.w600)),
),
),
],
),
),
),
);
}
Widget _credLine(
String label, {
String? value,
Color? color,
Color? labelColor,
Color? valueColor,
bool isComment = false,
bool isPassword = false,
}) {
if (isComment) {
return Text(
label,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: color ?? const Color(0xFF6A9955),
height: 1.4),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: labelColor ?? const Color(0xFF9CDCFE),
fontWeight: FontWeight.w600),
),
const Text(' ',
style: TextStyle(fontFamily: 'monospace', fontSize: 13)),
Expanded(
child: SelectableText(
value ?? '',
style: TextStyle(
fontFamily: 'monospace',
fontSize: isPassword ? 15 : 13,
color: valueColor ?? const Color(0xFFD4D4D4),
fontWeight:
isPassword ? FontWeight.w700 : FontWeight.normal,
letterSpacing: isPassword ? 1.5 : 0,
),
),
),
],
);
}
Widget _shareButton({
required IconData icon,
required String label,
required Color color,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Column(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(height: 4),
Text(label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color)),
],
),
),
);
}
}
// ─── Bottom sheet ─────────────────────────────────────────────────────────────
class AddMemberSheet extends StatefulWidget {
final AuthState authState;
final void Function(MembreCompletModel) onCreated;
const AddMemberSheet({
super.key,
required this.authState,
required this.onCreated,
});
@override
State<AddMemberSheet> createState() => _AddMemberSheetState();
}
class _AddMemberSheetState extends State<AddMemberSheet> {
final _formKey = GlobalKey<FormState>();
// Requis
final _prenomCtrl = TextEditingController();
final _nomCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
DateTime? _dateNaissance;
bool _dateMissing = false;
// Optionnels
final _telephoneCtrl = TextEditingController();
final _telephoneWaveCtrl = TextEditingController();
final _professionCtrl = TextEditingController();
final _nationaliteCtrl = TextEditingController();
final _numeroIdentiteCtrl = TextEditingController();
String? _statutMatrimonial;
String? _typeIdentite;
bool _showOptional = false;
bool _isSubmitting = false;
String? _errorMessage;
// Sélection organisation (superadmin uniquement)
String? _selectedOrganisationId;
String? _selectedOrganisationNom;
List<Map<String, String>> _organisations = [];
bool _loadingOrgs = false;
bool get _isSuperAdmin {
final auth = widget.authState;
return auth is AuthAuthenticated &&
auth.user.primaryRole == UserRole.superAdmin;
}
@override
void initState() {
super.initState();
if (_isSuperAdmin) _loadOrganisations();
}
Future<void> _loadOrganisations() async {
setState(() => _loadingOrgs = true);
try {
final repo = GetIt.instance<IOrganizationRepository>();
final orgs = await repo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_organisations = orgs
.where((o) => o.id != null && o.id!.isNotEmpty)
.map((o) => {'id': o.id!, 'nom': o.nomAffichage})
.toList();
});
}
} catch (e) {
// Non bloquant : le sélecteur affichera un message d'erreur
} finally {
if (mounted) setState(() => _loadingOrgs = false);
}
}
static const _statutOptions = [
'CELIBATAIRE',
'MARIE',
'DIVORCE',
'VEUF',
];
static const _statutLabels = {
'CELIBATAIRE': 'Célibataire',
'MARIE': 'Marié(e)',
'DIVORCE': 'Divorcé(e)',
'VEUF': 'Veuf / Veuve',
};
static const _identiteOptions = [
'CNI',
'PASSEPORT',
'PERMIS_CONDUIRE',
'TITRE_SEJOUR',
];
static const _identiteLabels = {
'CNI': "Carte Nationale d'Identité",
'PASSEPORT': 'Passeport',
'PERMIS_CONDUIRE': 'Permis de conduire',
'TITRE_SEJOUR': 'Titre de séjour',
};
@override
void dispose() {
_prenomCtrl.dispose();
_nomCtrl.dispose();
_emailCtrl.dispose();
_telephoneCtrl.dispose();
_telephoneWaveCtrl.dispose();
_professionCtrl.dispose();
_nationaliteCtrl.dispose();
_numeroIdentiteCtrl.dispose();
super.dispose();
}
String? _resolveOrganisationId() {
// Superadmin : utilise l'org sélectionnée dans le sélecteur
if (_isSuperAdmin) return _selectedOrganisationId;
final auth = widget.authState;
if (auth is AuthAuthenticated) {
return auth.user.organizationContexts.isNotEmpty
? auth.user.organizationContexts.first.organizationId
: null;
}
if (auth is AuthPendingOnboarding) return auth.organisationId;
return null;
}
void _submit() {
final formValid = _formKey.currentState!.validate();
final dateValid = _dateNaissance != null;
final orgValid = !_isSuperAdmin || _selectedOrganisationId != null;
setState(() {
_dateMissing = !dateValid;
if (!orgValid) _errorMessage = 'Veuillez sélectionner une organisation.';
});
if (!formValid || !dateValid || !orgValid) return;
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
final membre = MembreCompletModel(
prenom: _prenomCtrl.text.trim(),
nom: _nomCtrl.text.trim(),
email: _emailCtrl.text.trim().toLowerCase(),
dateNaissance: _dateNaissance,
telephone: _val(_telephoneCtrl),
telephoneWave: _val(_telephoneWaveCtrl),
profession: _val(_professionCtrl),
nationalite: _val(_nationaliteCtrl),
statutMatrimonial: _statutMatrimonial,
typeIdentite: _typeIdentite,
numeroIdentite: _val(_numeroIdentiteCtrl),
organisationId: _resolveOrganisationId(),
statut: StatutMembre.actif,
);
context.read<MembresBloc>().add(CreateMembre(membre));
}
String? _val(TextEditingController ctrl) {
final v = ctrl.text.trim();
return v.isNotEmpty ? v : null;
}
@override
Widget build(BuildContext context) {
return BlocListener<MembresBloc, MembresState>(
listener: (ctx, state) {
if (state is MembresLoading) {
if (mounted) setState(() => _isSubmitting = true);
} else if (state is MembreCreated) {
// Fermer la sheet puis afficher le dialog credentials (gère mot de passe null ou non)
Navigator.pop(ctx);
widget.onCreated(state.membre);
} else if (state is MembresError || state is MembresValidationError) {
final msg = state is MembresValidationError
? state.validationErrors.values.join(' · ')
: (state as MembresError).message;
if (mounted) {
setState(() {
_isSubmitting = false;
_errorMessage = msg;
});
}
} else {
if (mounted) setState(() => _isSubmitting = false);
}
},
child: DraggableScrollableSheet(
initialChildSize: 0.92,
maxChildSize: 0.97,
minChildSize: 0.5,
builder: (_, scrollCtrl) => Container(
decoration: const BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
_buildHandle(),
_buildHeader(),
const Divider(height: 1, color: UnionFlowColors.border),
if (_errorMessage != null) _buildErrorBanner(),
Expanded(child: _buildForm(scrollCtrl)),
_buildFooter(),
],
),
),
),
);
}
// ── Handle ─────────────────────────────────────────────────────────────────
Widget _buildHandle() => Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 6),
width: 36,
height: 4,
decoration: BoxDecoration(
color: UnionFlowColors.border,
borderRadius: BorderRadius.circular(2),
),
),
);
// ── Header ─────────────────────────────────────────────────────────────────
Widget _buildHeader() => Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 8, 14),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: UnionFlowColors.unionGreenPale,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.person_add_rounded,
color: UnionFlowColors.unionGreen, size: 20),
),
const SizedBox(width: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nouveau membre',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary),
),
Text(
'Compte Keycloak provisionné automatiquement',
style: TextStyle(
fontSize: 11, color: UnionFlowColors.textSecondary),
),
],
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close_rounded,
size: 20, color: UnionFlowColors.textSecondary),
onPressed: () => Navigator.pop(context),
),
],
),
);
// ── Bannière erreur ────────────────────────────────────────────────────────
Widget _buildErrorBanner() => Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: UnionFlowColors.errorPale,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.errorLight),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
size: 16, color: UnionFlowColors.error),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
fontSize: 12, color: UnionFlowColors.error),
),
),
],
),
);
// ── Formulaire ─────────────────────────────────────────────────────────────
Widget _buildOrgPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Organisation', required: true),
const SizedBox(height: 10),
if (_loadingOrgs)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (_organisations.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UnionFlowColors.warningPale,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.warning.withOpacity(0.4)),
),
child: const Text(
'Aucune organisation disponible. Créez d\'abord une organisation.',
style: TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary),
),
)
else
DropdownButtonFormField<String>(
value: _selectedOrganisationId,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.business_outlined, size: 18,
color: UnionFlowColors.textSecondary),
hintText: 'Sélectionner une organisation',
hintStyle: const TextStyle(fontSize: 13, color: UnionFlowColors.textTertiary),
contentPadding: const EdgeInsets.symmetric(vertical: 13, horizontal: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.4)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5),
),
filled: true,
fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3),
),
isExpanded: true,
items: _organisations
.map((org) => DropdownMenuItem<String>(
value: org['id'],
child: Text(
org['nom']!,
style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary),
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (val) => setState(() {
_selectedOrganisationId = val;
_selectedOrganisationNom = _organisations
.firstWhere((o) => o['id'] == val, orElse: () => {})['nom'];
_errorMessage = null;
}),
validator: (v) => v == null ? 'Obligatoire' : null,
),
const SizedBox(height: 20),
],
);
}
Widget _buildForm(ScrollController scrollCtrl) => SingleChildScrollView(
controller: scrollCtrl,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélecteur organisation — superadmin uniquement
if (_isSuperAdmin) _buildOrgPicker(),
// Identité — requis
_sectionLabel('Identité', required: true),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _field(_prenomCtrl, 'Prénom',
required: true, icon: Icons.badge_outlined),
),
const SizedBox(width: 10),
Expanded(
child: _field(_nomCtrl, 'Nom', required: true),
),
],
),
const SizedBox(height: 10),
_field(
_emailCtrl,
'Adresse email',
required: true,
icon: Icons.alternate_email_rounded,
keyboard: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Obligatoire';
if (!RegExp(r'^[\w.+\-]+@[\w\-]+\.[a-z]{2,}$')
.hasMatch(v.trim())) {
return 'Format invalide';
}
return null;
},
),
const SizedBox(height: 10),
_datePicker(),
const SizedBox(height: 20),
// Section optionnelle — accordéon
_optionalToggle(),
if (_showOptional) ...[
const SizedBox(height: 16),
_sectionLabel('Contact'),
const SizedBox(height: 10),
_field(_telephoneCtrl, 'Téléphone',
icon: Icons.phone_outlined,
keyboard: TextInputType.phone,
hint: 'ex: +22507XXXXXXXX',
maxLength: 20,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) {
return 'Format E.164 requis (ex: +22507XXXXXXXX)';
}
return null;
}),
const SizedBox(height: 10),
_field(_telephoneWaveCtrl, 'Numéro Wave',
icon: Icons.waves_rounded,
keyboard: TextInputType.phone,
hint: 'ex: +22507XXXXXXXX',
maxLength: 20,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (!RegExp(r'^\+[1-9][0-9]{6,14}$').hasMatch(v.trim())) {
return 'Format E.164 requis (ex: +22507XXXXXXXX)';
}
return null;
}),
const SizedBox(height: 18),
_sectionLabel('Profil'),
const SizedBox(height: 10),
_field(_professionCtrl, 'Profession',
icon: Icons.work_outline_rounded, maxLength: 100),
const SizedBox(height: 10),
_field(_nationaliteCtrl, 'Nationalité',
icon: Icons.flag_outlined, maxLength: 100),
const SizedBox(height: 10),
_dropdown(
label: 'Statut matrimonial',
value: _statutMatrimonial,
items: _statutOptions,
labels: _statutLabels,
icon: Icons.favorite_border_rounded,
onChanged: (v) => setState(() => _statutMatrimonial = v),
),
const SizedBox(height: 18),
_sectionLabel("Pièce d'identité"),
const SizedBox(height: 10),
_dropdown(
label: 'Type de pièce',
value: _typeIdentite,
items: _identiteOptions,
labels: _identiteLabels,
icon: Icons.credit_card_rounded,
onChanged: (v) => setState(() => _typeIdentite = v),
),
const SizedBox(height: 10),
_field(_numeroIdentiteCtrl, 'Numéro',
icon: Icons.numbers_rounded, maxLength: 100),
],
const SizedBox(height: 12),
],
),
),
);
// ── Pied de page ───────────────────────────────────────────────────────────
Widget _buildFooter() => Container(
padding: EdgeInsets.fromLTRB(
20, 12, 20, 16 + MediaQuery.of(context).viewInsets.bottom),
decoration: const BoxDecoration(
color: UnionFlowColors.surface,
border: Border(top: BorderSide(color: UnionFlowColors.border)),
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: UnionFlowColors.border),
foregroundColor: UnionFlowColors.textSecondary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
child: const Text('Annuler'),
),
),
const SizedBox(width: 10),
Expanded(
flex: 2,
child: FilledButton(
onPressed: _isSubmitting ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add_rounded, size: 16),
SizedBox(width: 8),
Text('Créer le membre',
style: TextStyle(fontWeight: FontWeight.w600)),
],
),
),
),
],
),
);
// ── Composants internes ────────────────────────────────────────────────────
Widget _sectionLabel(String label, {bool required = false}) => Row(
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textTertiary,
letterSpacing: 1,
),
),
if (required) ...[
const SizedBox(width: 4),
const Text('*',
style: TextStyle(
color: UnionFlowColors.error,
fontSize: 12,
fontWeight: FontWeight.w700)),
],
],
);
Widget _optionalToggle() => InkWell(
onTap: () => setState(() => _showOptional = !_showOptional),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
color: UnionFlowColors.surfaceVariant,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.border),
),
child: Row(
children: [
const Icon(Icons.tune_rounded,
size: 16, color: UnionFlowColors.textSecondary),
const SizedBox(width: 8),
const Text(
'Informations complémentaires',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textSecondary),
),
const Spacer(),
Text(
_showOptional ? 'Réduire' : 'Développer',
style: const TextStyle(
fontSize: 11, color: UnionFlowColors.unionGreen),
),
Icon(
_showOptional ? Icons.expand_less : Icons.expand_more,
size: 18,
color: UnionFlowColors.unionGreen,
),
],
),
),
);
InputDecoration _inputDeco(String label,
{IconData? icon, String? hint, int? maxLength, bool required = false}) {
return InputDecoration(
labelText: required ? '$label *' : label,
hintText: hint,
counterText: '',
prefixIcon: icon != null
? Icon(icon, size: 18, color: UnionFlowColors.textTertiary)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: UnionFlowColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: UnionFlowColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: UnionFlowColors.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: UnionFlowColors.error, width: 1.5),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
filled: true,
fillColor: UnionFlowColors.surface,
labelStyle:
const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary),
);
}
Widget _field(
TextEditingController ctrl,
String label, {
bool required = false,
IconData? icon,
TextInputType? keyboard,
String? Function(String?)? validator,
String? hint,
int? maxLength,
}) {
return TextFormField(
controller: ctrl,
keyboardType: keyboard,
maxLength: maxLength,
style:
const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
decoration: _inputDeco(label,
icon: icon, hint: hint, maxLength: maxLength, required: required),
validator: validator ??
(required
? (v) =>
(v == null || v.trim().isEmpty) ? 'Champ obligatoire' : null
: null),
);
}
Widget _dropdown({
required String label,
required String? value,
required List<String> items,
required Map<String, String> labels,
required IconData icon,
required void Function(String?) onChanged,
}) {
return DropdownButtonFormField<String>(
value: value,
isExpanded: true,
decoration: _inputDeco(label, icon: icon),
style:
const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
items: items
.map((k) => DropdownMenuItem(
value: k,
child: Text(labels[k] ?? k,
style: const TextStyle(fontSize: 14)),
))
.toList(),
onChanged: onChanged,
);
}
Widget _datePicker() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(10),
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Date de naissance *',
prefixIcon: const Icon(Icons.calendar_today_rounded,
size: 18, color: UnionFlowColors.textTertiary),
suffixIcon: const Icon(Icons.chevron_right_rounded,
size: 18, color: UnionFlowColors.textTertiary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: _dateMissing
? UnionFlowColors.error
: UnionFlowColors.border,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: _dateMissing
? UnionFlowColors.error
: _dateNaissance != null
? UnionFlowColors.unionGreen
: UnionFlowColors.border,
),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
filled: true,
fillColor: UnionFlowColors.surface,
labelStyle: const TextStyle(
fontSize: 13, color: UnionFlowColors.textSecondary),
),
child: Text(
_dateNaissance != null
? DateFormat('dd MMMM yyyy', 'fr').format(_dateNaissance!)
: 'Sélectionner une date',
style: TextStyle(
fontSize: 14,
color: _dateNaissance != null
? UnionFlowColors.textPrimary
: UnionFlowColors.textTertiary,
),
),
),
),
if (_dateMissing)
const Padding(
padding: EdgeInsets.only(top: 4, left: 14),
child: Text(
'La date de naissance est obligatoire',
style: TextStyle(fontSize: 11, color: UnionFlowColors.error),
),
),
],
);
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _dateNaissance ?? DateTime(1990),
firstDate: DateTime(1900),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)),
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: Theme.of(ctx).colorScheme.copyWith(
primary: UnionFlowColors.unionGreen,
),
),
child: child!,
),
);
if (picked != null) {
setState(() {
_dateNaissance = picked;
_dateMissing = false;
});
}
}
}