1015 lines
40 KiB
Dart
Executable File
1015 lines
40 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 '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 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 ?? '');
|
|
|
|
_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');
|
|
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');
|
|
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;
|
|
}
|
|
|
|
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
|
|
String _generatePassword() {
|
|
// Listes de mots pour créer des phrases de passe mémorables
|
|
final sujets = [
|
|
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
|
|
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
|
|
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
|
|
];
|
|
|
|
final noms = [
|
|
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
|
|
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
|
|
];
|
|
|
|
final verbes = [
|
|
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
|
|
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
|
|
];
|
|
|
|
final complements = [
|
|
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
|
|
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
|
|
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
|
|
];
|
|
|
|
// Choisir un type de phrase aléatoirement
|
|
final typePhrase = _random.nextInt(3);
|
|
String phrase;
|
|
|
|
switch (typePhrase) {
|
|
case 0:
|
|
// Type: Sujet + nom propre + verbe + complément
|
|
final sujet = sujets[_random.nextInt(sujets.length)];
|
|
final nom = noms[_random.nextInt(noms.length)];
|
|
final verbe = verbes[_random.nextInt(verbes.length)];
|
|
final complement = complements[_random.nextInt(complements.length)];
|
|
phrase = '$sujet $nom $verbe $complement';
|
|
break;
|
|
|
|
case 1:
|
|
// Type: Nom propre + a + nombre + ans + point d'exclamation
|
|
final nom = noms[_random.nextInt(noms.length)];
|
|
final age = 1 + _random.nextInt(20);
|
|
phrase = '$nom a $age ans!';
|
|
break;
|
|
|
|
default:
|
|
// Type: Sujet + verbe + nombre + complément
|
|
final sujet = sujets[_random.nextInt(sujets.length)];
|
|
final verbe = verbes[_random.nextInt(verbes.length)];
|
|
final nombre = 1 + _random.nextInt(100);
|
|
final complement = complements[_random.nextInt(complements.length)];
|
|
phrase = '$sujet $verbe $nombre fois $complement';
|
|
}
|
|
|
|
// Ajouter éventuellement un caractère spécial à la fin
|
|
if (_random.nextBool()) {
|
|
final speciaux = ['!', '?', '.', '...', '♥', '☀', '★', '♪'];
|
|
phrase += speciaux[_random.nextInt(speciaux.length)];
|
|
}
|
|
|
|
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
|
|
if (phrase.length < 8) {
|
|
phrase += ' ${1000 + _random.nextInt(9000)}';
|
|
}
|
|
|
|
// Tronquer si trop long (max 64 caractères selon NIST)
|
|
if (phrase.length > 64) {
|
|
phrase = phrase.substring(0, 64);
|
|
}
|
|
|
|
return phrase;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
@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 (seulement en création, jamais en modification)
|
|
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
|
|
// 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,
|
|
isRequired: true,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return "Veuillez entrer l'adresse email";
|
|
}
|
|
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: (widget.user?.id == 0 && 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
|
|
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
|
|
: null,
|
|
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: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Bouton pour afficher/masquer le mot de passe
|
|
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",
|
|
),
|
|
// Bouton pour générer un mot de passe (seulement si éditable)
|
|
if (!widget.readOnly)
|
|
IconButton(
|
|
icon: Icon(Icons.auto_awesome),
|
|
onPressed: () {
|
|
final newPassword = _generatePassword();
|
|
setState(() {
|
|
_passwordController.text = newPassword;
|
|
_obscurePassword = false; // Afficher le mot de passe généré
|
|
});
|
|
// Revalider le formulaire
|
|
_formKey.currentState?.validate();
|
|
},
|
|
tooltip: "Générer un mot de passe sécurisé",
|
|
),
|
|
],
|
|
),
|
|
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: (widget.user?.id == 0 && 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
|
|
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
|
|
: null,
|
|
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: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Bouton pour afficher/masquer le mot de passe
|
|
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",
|
|
),
|
|
// Bouton pour générer un mot de passe (seulement si éditable)
|
|
if (!widget.readOnly)
|
|
IconButton(
|
|
icon: Icon(Icons.auto_awesome),
|
|
onPressed: () {
|
|
final newPassword = _generatePassword();
|
|
setState(() {
|
|
_passwordController.text = newPassword;
|
|
_obscurePassword = false; // Afficher le mot de passe généré
|
|
});
|
|
// Revalider le formulaire
|
|
_formKey.currentState?.validate();
|
|
},
|
|
tooltip: "Générer un mot de passe sécurisé",
|
|
),
|
|
],
|
|
),
|
|
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: [
|
|
Radio<int>(
|
|
value: value,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
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;
|