import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'dart:math'; import 'package:geosector_app/core/data/models/user_model.dart'; import 'package:geosector_app/core/data/models/amicale_model.dart'; import 'package:geosector_app/core/services/api_service.dart'; import 'package:geosector_app/core/utils/api_exception.dart'; import 'custom_text_field.dart'; class UserForm extends StatefulWidget { final UserModel? user; final Function(UserModel)? onSubmit; final bool readOnly; final bool allowUsernameEdit; final bool allowSectNameEdit; final AmicaleModel? amicale; // Nouveau paramètre pour l'amicale final bool isAdmin; // Nouveau paramètre pour savoir si c'est un admin const UserForm({ super.key, this.user, this.onSubmit, this.readOnly = false, this.allowUsernameEdit = false, this.allowSectNameEdit = false, this.amicale, this.isAdmin = false, }); @override State createState() => _UserFormState(); } class _UserFormState extends State { final _formKey = GlobalKey(); // Controllers late final TextEditingController _usernameController; late final TextEditingController _firstNameController; late final TextEditingController _nameController; late final TextEditingController _sectNameController; late final TextEditingController _phoneController; late final TextEditingController _mobileController; late final TextEditingController _emailController; late final TextEditingController _dateNaissanceController; late final TextEditingController _dateEmbaucheController; late final TextEditingController _passwordController; // Nouveau controller pour le mot de passe // Form values int _fkTitre = 1; // 1 = M., 2 = Mme DateTime? _dateNaissance; DateTime? _dateEmbauche; // Pour la génération automatique d'username bool _isGeneratingUsername = false; final Random _random = Random(); // Pour détecter la modification du username String? _initialUsername; // Pour afficher/masquer le mot de passe bool _obscurePassword = true; @override void initState() { super.initState(); // Initialize controllers with user data if available final user = widget.user; _usernameController = TextEditingController(text: user?.username ?? ''); _firstNameController = TextEditingController(text: user?.firstName ?? ''); _nameController = TextEditingController(text: user?.name ?? ''); _sectNameController = TextEditingController(text: user?.sectName ?? ''); _phoneController = TextEditingController(text: user?.phone ?? ''); _mobileController = TextEditingController(text: user?.mobile ?? ''); _emailController = TextEditingController(text: user?.email ?? ''); // Stocker le username initial pour détecter les modifications _initialUsername = user?.username; _dateNaissance = user?.dateNaissance; _dateEmbauche = user?.dateEmbauche; _dateNaissanceController = TextEditingController(text: _dateNaissance != null ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) : ''); _dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : ''); _passwordController = TextEditingController(); // Initialiser le controller du mot de passe _fkTitre = user?.fkTitre ?? 1; // Ajouter des listeners pour auto-générer l'username en création if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) { _nameController.addListener(_onNameOrSectNameChanged); _sectNameController.addListener(_onNameOrSectNameChanged); } } void _onNameOrSectNameChanged() { // Auto-générer username seulement en création et si le champ username est vide if (widget.user?.id == 0 && _usernameController.text.isEmpty && (_nameController.text.isNotEmpty || _sectNameController.text.isNotEmpty)) { _generateAndCheckUsername(); } } @override void dispose() { // Retirer les listeners si ajoutés if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) { _nameController.removeListener(_onNameOrSectNameChanged); _sectNameController.removeListener(_onNameOrSectNameChanged); } _usernameController.dispose(); _firstNameController.dispose(); _nameController.dispose(); _sectNameController.dispose(); _phoneController.dispose(); _mobileController.dispose(); _emailController.dispose(); _dateNaissanceController.dispose(); _dateEmbaucheController.dispose(); _passwordController.dispose(); super.dispose(); } // Validation conditionnelle pour name/sectName String? _validateNameOrSectName(String? value, bool isNameField) { final nameValue = _nameController.text.trim(); final sectNameValue = _sectNameController.text.trim(); // Si les deux sont vides if (nameValue.isEmpty && sectNameValue.isEmpty) { return isNameField ? "Veuillez renseigner soit le nom soit le nom de tournée" : "Veuillez renseigner soit le nom de tournée soit le nom"; } return null; // Validation OK si au moins un des deux est rempli } // Méthode simplifiée pour sélectionner une date void _selectDate(BuildContext context, bool isDateNaissance) { // Utiliser un bloc try-catch pour capturer toutes les erreurs possibles try { // Déterminer la date initiale DateTime initialDate; if (isDateNaissance) { initialDate = _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 30)); // 30 ans par défaut } else { initialDate = _dateEmbauche ?? DateTime.now(); } // S'assurer que la date initiale est dans la plage autorisée if (initialDate.isAfter(DateTime.now())) { initialDate = DateTime.now(); } if (initialDate.isBefore(DateTime(1900))) { initialDate = DateTime(1950); } // Afficher le sélecteur de date avec locale française showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(1900), lastDate: DateTime.now(), locale: const Locale('fr', 'FR'), // Forcer la locale française builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: Theme.of(context).colorScheme.copyWith( primary: Theme.of(context).colorScheme.primary, onPrimary: Colors.white, surface: Colors.white, onSurface: Colors.black, ), ), child: child!, ); }, helpText: isDateNaissance ? 'SÉLECTIONNER LA DATE DE NAISSANCE' : 'SÉLECTIONNER LA DATE D\'EMBAUCHE', cancelText: 'ANNULER', confirmText: 'VALIDER', fieldLabelText: 'Entrer une date', fieldHintText: 'jj/mm/aaaa', errorFormatText: 'Format de date invalide', errorInvalidText: 'Date invalide', ).then((DateTime? picked) { // Vérifier si une date a été sélectionnée if (picked != null) { setState(() { // Mettre à jour la date et le texte du contrôleur if (isDateNaissance) { _dateNaissance = picked; _dateNaissanceController.text = DateFormat('dd/MM/yyyy').format(picked); } else { _dateEmbauche = picked; _dateEmbaucheController.text = DateFormat('dd/MM/yyyy').format(picked); } }); } }).catchError((error) { // Gérer les erreurs spécifiques au sélecteur de date debugPrint('Erreur lors de la sélection de la date: $error'); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Erreur lors de la sélection de la date'), backgroundColor: Colors.red, ), ); } }); } catch (e) { // Gérer toutes les autres erreurs debugPrint('Exception lors de l\'affichage du sélecteur de date: $e'); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Impossible d\'afficher le sélecteur de date'), backgroundColor: Colors.red, ), ); } } } // Nettoyer une chaîne pour l'username (NIST: garder plus de caractères) String _cleanString(String input) { // Avec NIST, on peut garder plus de caractères, mais pour la génération automatique // on reste sur des caractères simples pour éviter les problèmes return input.toLowerCase() .replaceAll(RegExp(r'[^a-z0-9\s]'), ''); // Garder lettres, chiffres et espaces } // Extraire une partie aléatoire d'une chaîne String _extractRandomPart(String input, int minLength, int maxLength) { if (input.isEmpty) return ''; final cleaned = _cleanString(input); if (cleaned.isEmpty) return ''; final length = minLength + _random.nextInt(maxLength - minLength + 1); if (cleaned.length <= length) return cleaned; // Prendre les premiers caractères jusqu'à la longueur désirée return cleaned.substring(0, length); } // Générer un username selon les nouvelles normes NIST String _generateUsername() { // Récupérer les données nécessaires final nom = _nameController.text.isNotEmpty ? _nameController.text : _sectNameController.text; final codePostal = widget.amicale?.codePostal ?? ''; final ville = widget.amicale?.ville ?? ''; // Nettoyer et extraire les parties final nomPart = _extractRandomPart(nom, 2, 5); final cpPart = _extractRandomPart(codePostal, 2, 3); final villePart = _extractRandomPart(ville, 2, 4); final nombreAleatoire = 10 + _random.nextInt(990); // 10 à 999 // Choisir des séparateurs aléatoires (on peut maintenant utiliser des espaces aussi) final separateurs = ['', '.', '_', '-', ' ']; final sep1 = separateurs[_random.nextInt(separateurs.length)]; final sep2 = separateurs[_random.nextInt(separateurs.length)]; // Assembler l'username String username = '$nomPart$sep1$cpPart$sep2$villePart$nombreAleatoire'; // Si trop court, ajouter des chiffres pour atteindre minimum 8 caractères (NIST) while (username.length < 8) { username += _random.nextInt(10).toString(); } // Avec NIST, on n'a plus besoin de nettoyer les caractères spéciaux // Tous les caractères sont acceptés return username; } // Vérifier la disponibilité d'un username via l'API Future> _checkUsernameAvailability(String username) async { try { final response = await ApiService.instance.post( '/users/check-username', data: {'username': username}, ); if (response.statusCode == 200) { return response.data; } return {'available': false}; } catch (e) { debugPrint('Erreur lors de la vérification de l\'username: $e'); return {'available': false}; } } // Générer et vérifier un username jusqu'à en trouver un disponible Future _generateAndCheckUsername() async { if (_isGeneratingUsername) return; // Éviter les appels multiples setState(() { _isGeneratingUsername = true; }); try { int attempts = 0; const maxAttempts = 10; while (attempts < maxAttempts) { final username = _generateUsername(); debugPrint('Tentative ${attempts + 1}: Vérification de $username'); final result = await _checkUsernameAvailability(username); if (result['available'] == true) { // Username disponible, l'utiliser setState(() { _usernameController.text = username; }); debugPrint('✅ Username disponible trouvé: $username'); break; } else { // Si l'API propose des suggestions, essayer la première if (result['suggestions'] != null && result['suggestions'].isNotEmpty) { final suggestion = result['suggestions'][0]; debugPrint('Vérification de la suggestion: $suggestion'); final suggestionResult = await _checkUsernameAvailability(suggestion); if (suggestionResult['available'] == true) { setState(() { _usernameController.text = suggestion; }); debugPrint('✅ Suggestion disponible utilisée: $suggestion'); break; } } } attempts++; } if (attempts >= maxAttempts) { debugPrint('⚠️ Impossible de trouver un username disponible après $maxAttempts tentatives'); } } finally { setState(() { _isGeneratingUsername = false; }); } } // Valider le mot de passe selon les normes NIST SP 800-63B String? _validatePassword(String? value) { if (value == null || value.isEmpty) { // Pour un nouveau membre, le mot de passe est obligatoire si le champ est affiché if (widget.user?.id == 0) { return "Veuillez entrer un mot de passe"; } return null; // En modification, vide = garder l'ancien } // Vérifier la longueur minimale (NIST: 8 caractères) if (value.length < 8) { return "Le mot de passe doit contenir au moins 8 caractères"; } // Vérifier la longueur maximale (NIST: 64 caractères) if (value.length > 64) { return "Le mot de passe ne doit pas dépasser 64 caractères"; } // NIST: Pas de vérification de composition obligatoire // Les espaces sont autorisés, tous les caractères sont acceptés // L'API vérifiera contre Have I Been Pwned // Pas de vérification password == username (demande client) return null; } // Méthode publique pour récupérer le mot de passe si défini String? getPassword() { final password = _passwordController.text; // NIST: ne pas faire de trim, les espaces sont autorisés return password.isNotEmpty ? password : null; } // Méthode publique pour valider et récupérer l'utilisateur UserModel? validateAndGetUser() { if (_formKey.currentState!.validate()) { return widget.user?.copyWith( username: _usernameController.text, // NIST: ne pas faire de trim sur username firstName: _firstNameController.text.trim(), name: _nameController.text.trim(), sectName: _sectNameController.text.trim(), phone: _phoneController.text.trim(), mobile: _mobileController.text.trim(), email: _emailController.text.trim(), fkTitre: _fkTitre, dateNaissance: _dateNaissance, dateEmbauche: _dateEmbauche, ) ?? UserModel( id: 0, username: _usernameController.text, // NIST: ne pas faire de trim sur username firstName: _firstNameController.text.trim(), name: _nameController.text.trim(), sectName: _sectNameController.text.trim(), phone: _phoneController.text.trim(), mobile: _mobileController.text.trim(), email: _emailController.text.trim(), fkTitre: _fkTitre, dateNaissance: _dateNaissance, dateEmbauche: _dateEmbauche, role: 1, createdAt: DateTime.now(), lastSyncedAt: DateTime.now(), ); } return null; } // Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username Future validateAndGetUserAsync(BuildContext context) async { if (!_formKey.currentState!.validate()) { return null; } // Déterminer si on doit afficher le champ username selon les règles final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true; // Déterminer si le username est éditable final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit; // Vérifier si le username a été modifié (seulement en mode édition) final currentUsername = _usernameController.text; final bool isUsernameModified = widget.user?.id != 0 && // Mode édition _initialUsername != null && _initialUsername != currentUsername && canEditUsername; // Si le username a été modifié, vérifier sa disponibilité if (isUsernameModified) { try { final result = await _checkUsernameAvailability(currentUsername); if (result['available'] != true) { // Afficher l'erreur if (context.mounted) { ApiException.showError( context, Exception(result['message'] ?? 'Ce nom d\'utilisateur est déjà utilisé') ); // Si des suggestions sont disponibles, les afficher if (result['suggestions'] != null && (result['suggestions'] as List).isNotEmpty) { final suggestions = (result['suggestions'] as List).take(3).join(', '); if (context.mounted) { ApiException.showError( context, Exception('Suggestions disponibles : $suggestions') ); } } } return null; // Bloquer la soumission } } catch (e) { // En cas d'erreur réseau ou autre if (context.mounted) { ApiException.showError( context, Exception('Impossible de vérifier la disponibilité du nom d\'utilisateur') ); } return null; } } // Si tout est OK, retourner l'utilisateur return widget.user?.copyWith( username: _usernameController.text, // NIST: ne pas faire de trim sur username firstName: _firstNameController.text.trim(), name: _nameController.text.trim(), sectName: _sectNameController.text.trim(), phone: _phoneController.text.trim(), mobile: _mobileController.text.trim(), email: _emailController.text.trim(), fkTitre: _fkTitre, dateNaissance: _dateNaissance, dateEmbauche: _dateEmbauche, ) ?? UserModel( id: 0, username: _usernameController.text, // NIST: ne pas faire de trim sur username firstName: _firstNameController.text.trim(), name: _nameController.text.trim(), sectName: _sectNameController.text.trim(), phone: _phoneController.text.trim(), mobile: _mobileController.text.trim(), email: _emailController.text.trim(), fkTitre: _fkTitre, dateNaissance: _dateNaissance, dateEmbauche: _dateEmbauche, role: 1, createdAt: DateTime.now(), lastSyncedAt: DateTime.now(), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isWideScreen = MediaQuery.of(context).size.width > 900; // Déterminer si on doit afficher le champ username selon les règles final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true; // Déterminer si le username est éditable final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit; // Déterminer si on doit afficher le champ mot de passe final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true; return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Email seul sur la première ligne CustomTextField( controller: _emailController, label: "Email", keyboardType: TextInputType.emailAddress, readOnly: widget.readOnly, validator: (value) { // Email optionnel - valider seulement si une valeur est saisie if (value != null && value.isNotEmpty) { if (!value.contains('@') || !value.contains('.')) { return "Veuillez entrer une adresse email valide"; } } return null; }, ), const SizedBox(height: 16), // Titre (M. ou Mme) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Titre", style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w500, color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 8), Row( children: [ _buildRadioOption( value: 1, label: 'M.', groupValue: _fkTitre, onChanged: widget.readOnly ? null : (value) { setState(() { _fkTitre = value!; }); }, ), const SizedBox(width: 40), _buildRadioOption( value: 2, label: 'Mme', groupValue: _fkTitre, onChanged: widget.readOnly ? null : (value) { setState(() { _fkTitre = value!; }); }, ), ], ), ], ), const SizedBox(height: 16), // Ligne 2: Prénom et Nom if (isWideScreen) Row( children: [ Expanded( child: CustomTextField( controller: _firstNameController, label: "Prénom", readOnly: widget.readOnly, ), ), const SizedBox(width: 16), Expanded( child: CustomTextField( controller: _nameController, label: "Nom", readOnly: widget.readOnly, validator: (value) => _validateNameOrSectName(value, true), onChanged: (value) { // Revalider sectName quand name change if (widget.allowSectNameEdit) { _formKey.currentState?.validate(); } }, ), ), ], ) else ...[ // Version mobile: Prénom et nom séparés CustomTextField( controller: _firstNameController, label: "Prénom", readOnly: widget.readOnly, ), const SizedBox(height: 16), CustomTextField( controller: _nameController, label: "Nom", readOnly: widget.readOnly, validator: (value) => _validateNameOrSectName(value, true), onChanged: (value) { // Revalider sectName quand name change if (widget.allowSectNameEdit) { _formKey.currentState?.validate(); } }, ), ], const SizedBox(height: 16), // Ligne 2.5: Nom de tournée (sectName) - uniquement si éditable if (widget.allowSectNameEdit) ...[ CustomTextField( controller: _sectNameController, label: "Nom de tournée", readOnly: widget.readOnly, validator: (value) => _validateNameOrSectName(value, false), onChanged: (value) { // Revalider name quand sectName change _formKey.currentState?.validate(); }, hintText: "Nom utilisé pour identifier la tournée", ), const SizedBox(height: 16), ], // Ligne 2: Téléphones (fixe et mobile) if (isWideScreen) Row( children: [ Expanded( child: CustomTextField( controller: _phoneController, label: "Téléphone fixe", keyboardType: TextInputType.phone, readOnly: widget.readOnly, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), ], validator: (value) { if (value != null && value.isNotEmpty && value.length < 10) { return "Le numéro doit contenir 10 chiffres"; } return null; }, ), ), const SizedBox(width: 16), Expanded( child: CustomTextField( controller: _mobileController, label: "Téléphone mobile", keyboardType: TextInputType.phone, readOnly: widget.readOnly, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), ], validator: (value) { if (value != null && value.isNotEmpty && value.length < 10) { return "Le numéro doit contenir 10 chiffres"; } return null; }, ), ), ], ) else ...[ // Version mobile: Téléphones séparés CustomTextField( controller: _phoneController, label: "Téléphone fixe", keyboardType: TextInputType.phone, readOnly: widget.readOnly, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), ], validator: (value) { if (value != null && value.isNotEmpty && value.length < 10) { return "Le numéro doit contenir 10 chiffres"; } return null; }, ), const SizedBox(height: 16), CustomTextField( controller: _mobileController, label: "Téléphone mobile", keyboardType: TextInputType.phone, readOnly: widget.readOnly, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), ], validator: (value) { if (value != null && value.isNotEmpty && value.length < 10) { return "Le numéro doit contenir 10 chiffres"; } return null; }, ), ], const SizedBox(height: 16), // Ligne 3: Username et Password (si applicable) if (shouldShowUsernameField || shouldShowPasswordField) ...[ if (isWideScreen) Row( children: [ if (shouldShowUsernameField) Expanded( child: CustomTextField( controller: _usernameController, label: "Nom d'utilisateur", readOnly: !canEditUsername, prefixIcon: Icons.account_circle, isRequired: canEditUsername, suffixIcon: canEditUsername ? _isGeneratingUsername ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), ), ) : IconButton( icon: Icon(Icons.refresh), onPressed: _generateAndCheckUsername, tooltip: "Générer un nom d'utilisateur", ) : null, helperText: canEditUsername ? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés." : "Identifiant de connexion", helperMaxLines: 2, validator: canEditUsername ? (value) { if (value == null || value.isEmpty) { return "Veuillez entrer le nom d'utilisateur"; } // Vérifier la longueur minimale (NIST: 8 caractères) if (value.length < 8) { return "Le nom d'utilisateur doit contenir au moins 8 caractères"; } // Vérifier la longueur maximale (NIST: 64 caractères) if (value.length > 64) { return "Le nom d'utilisateur ne doit pas dépasser 64 caractères"; } // Pas de vérification sur le type de caractères (NIST: tous acceptés) return null; } : null, ), ), if (shouldShowUsernameField && shouldShowPasswordField) const SizedBox(width: 16), if (shouldShowPasswordField) Expanded( child: CustomTextField( controller: _passwordController, label: "Mot de passe", obscureText: _obscurePassword, readOnly: widget.readOnly, prefixIcon: Icons.lock, suffixIcon: IconButton( icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off), onPressed: () { setState(() { _obscurePassword = !_obscurePassword; }); }, tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe", ), helperText: widget.user?.id != 0 ? "Laissez vide pour conserver le mot de passe actuel" : "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)", helperMaxLines: 3, validator: _validatePassword, ), ), // Si seulement un des deux est affiché, ajouter un Expanded vide pour garder l'alignement if ((shouldShowUsernameField && !shouldShowPasswordField) || (!shouldShowUsernameField && shouldShowPasswordField)) const Expanded(child: SizedBox()), ], ) else ...[ // Version mobile: Username et Password séparés if (shouldShowUsernameField) ...[ CustomTextField( controller: _usernameController, label: "Nom d'utilisateur", readOnly: !canEditUsername, prefixIcon: Icons.account_circle, isRequired: canEditUsername, suffixIcon: canEditUsername ? _isGeneratingUsername ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), ), ) : IconButton( icon: Icon(Icons.refresh), onPressed: _generateAndCheckUsername, tooltip: "Générer un nom d'utilisateur", ) : null, helperText: canEditUsername ? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés." : "Identifiant de connexion", helperMaxLines: 2, validator: canEditUsername ? (value) { if (value == null || value.isEmpty) { return "Veuillez entrer le nom d'utilisateur"; } // Vérifier la longueur minimale (NIST: 8 caractères) if (value.length < 8) { return "Le nom d'utilisateur doit contenir au moins 8 caractères"; } // Vérifier la longueur maximale (NIST: 64 caractères) if (value.length > 64) { return "Le nom d'utilisateur ne doit pas dépasser 64 caractères"; } // Pas de vérification sur le type de caractères (NIST: tous acceptés) return null; } : null, ), const SizedBox(height: 16), ], if (shouldShowPasswordField) ...[ CustomTextField( controller: _passwordController, label: "Mot de passe", obscureText: _obscurePassword, readOnly: widget.readOnly, prefixIcon: Icons.lock, suffixIcon: IconButton( icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off), onPressed: () { setState(() { _obscurePassword = !_obscurePassword; }); }, tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe", ), helperText: widget.user?.id != 0 ? "Laissez vide pour conserver le mot de passe actuel" : "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)", helperMaxLines: 3, validator: _validatePassword, ), const SizedBox(height: 16), ], ], const SizedBox(height: 16), ], // Ligne 4: Dates (naissance et embauche) if (isWideScreen) Row( children: [ Expanded( child: CustomTextField( controller: _dateNaissanceController, label: "Date de naissance", readOnly: true, onTap: widget.readOnly ? null : () => _selectDate(context, true), suffixIcon: Icon( Icons.calendar_today, color: theme.colorScheme.primary, ), ), ), const SizedBox(width: 16), Expanded( child: CustomTextField( controller: _dateEmbaucheController, label: "Date d'embauche", readOnly: true, onTap: widget.readOnly ? null : () => _selectDate(context, false), suffixIcon: Icon( Icons.calendar_today, color: theme.colorScheme.primary, ), ), ), ], ) else ...[ // Version mobile: Dates séparées CustomTextField( controller: _dateNaissanceController, label: "Date de naissance", readOnly: true, onTap: widget.readOnly ? null : () => _selectDate(context, true), suffixIcon: Icon( Icons.calendar_today, color: theme.colorScheme.primary, ), ), const SizedBox(height: 16), CustomTextField( controller: _dateEmbaucheController, label: "Date d'embauche", readOnly: true, onTap: widget.readOnly ? null : () => _selectDate(context, false), suffixIcon: Icon( Icons.calendar_today, color: theme.colorScheme.primary, ), ), ], const SizedBox(height: 16), ], ), ); } Widget _buildRadioOption({ required int value, required String label, required int groupValue, required Function(int?)? onChanged, }) { final theme = Theme.of(context); return Row( children: [ // TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+) Radio( value: value, groupValue: groupValue, // ignore: deprecated_member_use onChanged: onChanged, // ignore: deprecated_member_use activeColor: const Color(0xFF20335E), ), Text( label, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), ], ); } } // Exporter la classe State pour pouvoir l'utiliser avec GlobalKey typedef UserFormState = _UserFormState;