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>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

@@ -5,6 +5,7 @@ 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 {
@@ -50,10 +51,13 @@ class _UserFormState extends State<UserForm> {
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;
@@ -72,6 +76,9 @@ class _UserFormState extends State<UserForm> {
_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;
@@ -373,80 +380,6 @@ class _UserFormState extends State<UserForm> {
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() {
@@ -489,6 +422,93 @@ class _UserFormState extends State<UserForm> {
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);
@@ -496,8 +516,8 @@ class _UserFormState extends State<UserForm> {
// 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 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;
@@ -512,13 +532,12 @@ class _UserFormState extends State<UserForm> {
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";
// 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;
},
@@ -731,7 +750,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -749,9 +768,9 @@ class _UserFormState extends State<UserForm> {
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,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -782,35 +801,14 @@ class _UserFormState extends State<UserForm> {
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é",
),
],
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"
@@ -833,7 +831,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -851,9 +849,9 @@ class _UserFormState extends State<UserForm> {
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,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -882,35 +880,14 @@ class _UserFormState extends State<UserForm> {
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é",
),
],
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"
@@ -996,10 +973,11 @@ class _UserFormState extends State<UserForm> {
return Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
groupValue: groupValue, // ignore: deprecated_member_use
onChanged: onChanged, // ignore: deprecated_member_use
activeColor: const Color(0xFF20335E),
),
Text(