Files
geo/app/lib/presentation/widgets/user_form.dart
pierre b6584c83fa feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API
- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 20:11:15 +02:00

997 lines
38 KiB
Dart
Executable File

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<UserForm> createState() => _UserFormState();
}
class _UserFormState extends State<UserForm> {
final _formKey = GlobalKey<FormState>();
// 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<Map<String, dynamic>> _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<void> _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<UserModel?> 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<Color>(
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<Color>(
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<int>(
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;