/// 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(); final authState = context.read().state; showModalBottomSheet( 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( 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 _shareViaSms() async { if (widget.credText == null) return; await Share.share(widget.credText!, subject: 'Identifiants UnionFlow'); } Future _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 createState() => _AddMemberSheetState(); } class _AddMemberSheetState extends State { final _formKey = GlobalKey(); // 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> _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 _loadOrganisations() async { setState(() => _loadingOrgs = true); try { final repo = GetIt.instance(); 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().add(CreateMembre(membre)); } String? _val(TextEditingController ctrl) { final v = ctrl.text.trim(); return v.isNotEmpty ? v : null; } @override Widget build(BuildContext context) { return BlocListener( 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( 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( 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 items, required Map labels, required IconData icon, required void Function(String?) onChanged, }) { return DropdownButtonFormField( 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 _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; }); } } }