feat: Livraison version 3.0.6

- Amélioration de la gestion des entités et des utilisateurs
- Mise à jour des modèles Amicale et Client avec champs supplémentaires
- Ajout du service de logging et amélioration du chargement UI
- Refactoring des formulaires utilisateur et amicale
- Intégration de file_picker et image_picker pour la gestion des fichiers
- Amélioration de la gestion des membres et de leur suppression
- Optimisation des performances de l'API
- Mise à jour de la documentation technique

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-08 20:33:54 +02:00
parent 599b9fcda0
commit 206c76c7db
69 changed files with 203569 additions and 174972 deletions

View File

@@ -97,6 +97,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
}
void _handleEditMembre(MembreModel membre) {
// Récupérer l'amicale actuelle
final amicale = widget.amicaleRepository.getUserAmicale(_currentUser!.fkEntite!);
showDialog(
context: context,
builder: (context) => UserFormDialog(
@@ -104,8 +107,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
user: membre.toUserModel(),
showRoleSelector: true,
showActiveCheckbox: true, // Activer la checkbox
allowUsernameEdit: true, // Permettre l'édition du username
// allowSectNameEdit sera automatiquement true via UserForm
allowUsernameEdit: amicale?.chkUsernameManuel == true, // Conditionnel selon amicale
amicale: amicale, // Passer l'amicale
isAdmin: true, // Car on est dans la page admin
availableRoles: const [
RoleOption(
value: 1,
@@ -118,23 +122,22 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (updatedUser) async {
onSubmit: (updatedUser, {String? password}) async {
try {
// Convertir le UserModel mis à jour vers MembreModel
final updatedMembre =
MembreModel.fromUserModel(updatedUser, membre);
// Utiliser directement updateMembre qui passe par l'API /users
final success =
await widget.membreRepository.updateMembre(updatedMembre);
final success = await widget.membreRepository.updateMembre(
updatedMembre,
password: password,
);
if (success && mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context,
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
} else if (!success && mounted) {
ApiException.showError(
context, Exception('Erreur lors de la mise à jour'));
}
} catch (e) {
debugPrint('❌ Erreur mise à jour membre: $e');
@@ -557,9 +560,6 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
if (success && mounted) {
ApiException.showSuccess(context,
'Membre ${membre.firstName} ${membre.name} désactivé avec succès');
} else if (mounted) {
ApiException.showError(
context, Exception('Erreur lors de la désactivation'));
}
} catch (e) {
if (mounted) {
@@ -571,6 +571,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
void _handleAddMembre() {
if (_currentUser?.fkEntite == null) return;
// Récupérer l'amicale actuelle
final amicale = widget.amicaleRepository.getUserAmicale(_currentUser!.fkEntite!);
// Créer un UserModel vide avec les valeurs par défaut
final newUser = UserModel(
id: 0, // ID temporaire pour nouveau membre
@@ -596,7 +599,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
user: newUser,
showRoleSelector: true,
showActiveCheckbox: true,
allowUsernameEdit: true,
allowUsernameEdit: amicale?.chkUsernameManuel == true, // Conditionnel selon amicale
amicale: amicale, // Passer l'amicale
isAdmin: true, // Car on est dans la page admin
availableRoles: const [
RoleOption(
value: 1,
@@ -609,7 +614,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (newUserData) async {
onSubmit: (newUserData, {String? password}) async {
try {
// Créer un nouveau MembreModel directement
final newMembre = MembreModel(
@@ -631,8 +636,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
);
// Créer le membre via l'API (retourne maintenant le membre créé)
final createdMembre =
await widget.membreRepository.createMembre(newMembre);
final createdMembre = await widget.membreRepository.createMembre(
newMembre,
password: password,
);
if (createdMembre != null && mounted) {
// Fermer le dialog

View File

@@ -434,7 +434,8 @@ class _LoginPageState extends State<LoginPage> {
'Login: Tentative avec type: $_loginType');
final success =
await userRepository.login(
await userRepository.loginWithSpinner(
context,
_usernameController.text.trim(),
_passwordController.text,
type: _loginType,
@@ -575,9 +576,9 @@ class _LoginPageState extends State<LoginPage> {
print(
'Login: Tentative avec type: $_loginType');
// Utiliser directement userRepository avec l'overlay de chargement
// Utiliser le nouveau spinner moderne pour la connexion
final success = await userRepository
.loginWithUI(
.loginWithSpinner(
context,
_usernameController.text.trim(),
_passwordController.text,

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'dart:convert';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'custom_text_field.dart';
class AmicaleForm extends StatefulWidget {
@@ -52,6 +58,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
bool _chkAcceptSms = false;
bool _chkActive = true;
bool _chkStripe = false;
bool _chkMdpManuel = false;
bool _chkUsernameManuel = false;
// Pour l'upload du logo
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
String? _logoUrl;
@override
void initState() {
@@ -78,6 +91,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
_chkActive = amicale?.chkActive ?? true;
_chkStripe = amicale?.chkStripe ?? false;
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
// Note : Le logo sera chargé dynamiquement depuis l'API
}
@override
@@ -133,6 +150,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
'chk_copie_mail_recu': amicale.chkCopieMailRecu ? 1 : 0,
'chk_accept_sms': amicale.chkAcceptSms ? 1 : 0,
'chk_stripe': amicale.chkStripe ? 1 : 0,
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
};
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
@@ -232,6 +251,115 @@ class _AmicaleFormState extends State<AmicaleForm> {
}
}
// Méthode pour sélectionner une image
Future<void> _selectImage() async {
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
if (image != null) {
// Vérifier la taille du fichier (limite 5 Mo)
final int fileSize = await image.length();
const int maxSize = 5 * 1024 * 1024; // 5 Mo en octets
if (fileSize > maxSize) {
// Fichier trop volumineux
final double sizeMB = fileSize / (1024 * 1024);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Le fichier est trop volumineux (${sizeMB.toStringAsFixed(2)} Mo). '
'La taille maximale autorisée est de 5 Mo.',
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
return;
}
setState(() {
_selectedImage = image;
});
// Upload immédiatement après sélection
if (widget.amicale?.id != null) {
await _uploadLogo();
}
}
} catch (e) {
debugPrint('Erreur lors de la sélection de l\'image: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sélection de l\'image: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Méthode pour uploader le logo
Future<void> _uploadLogo() async {
if (_selectedImage == null || widget.amicale?.id == null) return;
OverlayEntry? spinOverlay;
try {
// Afficher le spinner
spinOverlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Upload du logo en cours...',
blurAmount: 10.0,
showCard: true,
);
// Appeler l'API pour uploader le logo
final response = await widget.apiService?.uploadLogo(
widget.amicale!.id,
_selectedImage!,
);
if (response != null && response['status'] == 'success') {
// Succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logo uploadé avec succès'),
backgroundColor: Colors.green,
),
);
}
// Mettre à jour l'amicale avec le nouveau logo en base64
// Note : Le serveur devrait aussi mettre à jour le logo dans la session
// Pour l'instant on garde l'image sélectionnée en preview
setState(() {
// L'image reste en preview jusqu'au prochain login
});
}
} catch (e) {
debugPrint('Erreur lors de l\'upload du logo: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de l\'upload: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
// Fermer le spinner
LoadingSpinOverlayUtils.hideSpecific(spinOverlay);
}
}
void _submitForm() {
debugPrint('🔧 _submitForm appelée');
@@ -271,6 +399,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
@@ -291,6 +421,9 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
@@ -305,6 +438,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
// Construire la section logo
Widget _buildLogoSection() {
// Vérifier si on est admin d'amicale (role 2)
final userRole = widget.userRepository.getUserRole();
final canUploadLogo = userRole == 2 && !widget.readOnly;
return Container(
width: 150,
height: 150,
@@ -323,40 +460,40 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
// Image par défaut
// Afficher l'image sélectionnée, ou le logo depuis l'API, ou l'image par défaut
Center(
child: Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
),
child: _buildLogoImage(),
),
// Overlay pour indiquer que l'image est modifiable (si non en lecture seule)
if (!widget.readOnly)
// Overlay pour indiquer que l'image est modifiable (si admin d'amicale)
if (canUploadLogo)
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
// TODO: Implémenter la sélection d'image
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de modification du logo à venir'),
),
);
},
onTap: _selectImage,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withOpacity(0.3),
),
child: const Center(
child: Icon(
Icons.camera_alt,
color: Colors.white,
size: 40,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(
Icons.camera_alt,
color: Colors.white,
size: 32,
),
SizedBox(height: 4),
Text(
'Modifier',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
@@ -367,6 +504,102 @@ class _AmicaleFormState extends State<AmicaleForm> {
),
);
}
// Méthode pour construire l'image du logo
Widget _buildLogoImage() {
// 1. Si une image a été sélectionnée localement (preview)
if (_selectedImage != null) {
if (kIsWeb) {
return FutureBuilder<Uint8List>(
future: _selectedImage!.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(
snapshot.data!,
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
return const CircularProgressIndicator();
},
);
} else {
return Image.file(
File(_selectedImage!.path),
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
}
// 2. Si l'amicale a un logo en base64 stocké dans Hive
if (widget.amicale?.logoBase64 != null && widget.amicale!.logoBase64!.isNotEmpty) {
try {
// Le logoBase64 contient déjà le data URL complet (data:image/png;base64,...)
final dataUrl = widget.amicale!.logoBase64!;
// Extraire le base64 du data URL
final base64Data = dataUrl.split(',').last;
final bytes = base64Decode(base64Data);
return Image.memory(
bytes,
width: 150,
height: 150,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
debugPrint('Erreur affichage logo base64: $error');
// En cas d'erreur, essayer l'API
return _buildLogoFromApi();
},
);
} catch (e) {
debugPrint('Erreur décodage base64: $e');
// En cas d'erreur, essayer l'API
return _buildLogoFromApi();
}
}
// 3. Sinon, essayer de charger depuis l'API
return _buildLogoFromApi();
}
// Méthode pour charger le logo depuis l'API
Widget _buildLogoFromApi() {
if (widget.amicale?.id != null && widget.apiService != null) {
// Construire l'URL complète du logo
final logoUrl = '${widget.apiService!.baseUrl}/entites/${widget.amicale!.id}/logo';
return Image.network(
logoUrl,
width: 150,
height: 150,
fit: BoxFit.contain,
headers: {
'Authorization': 'Bearer ${widget.apiService!.sessionId ?? ""}',
},
errorBuilder: (context, error, stackTrace) {
// En cas d'erreur, afficher l'image par défaut
return Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
);
},
);
}
// Par défaut, afficher l'image locale
return Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
// Construire la minimap
Widget _buildMiniMap() {
@@ -825,59 +1058,106 @@ class _AmicaleFormState extends State<AmicaleForm> {
),
const SizedBox(height: 8),
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Options organisées sur 2 colonnes
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Colonne de gauche
Expanded(
child: Column(
children: [
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
],
),
),
const SizedBox(width: 32),
// Colonne de droite
Expanded(
child: Column(
children: [
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
// Checkbox Mot de passe manuel
_buildCheckboxOption(
label: "Saisie manuelle des mots de passe",
value: _chkMdpManuel,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkMdpManuel = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Username manuel
_buildCheckboxOption(
label: "Saisie manuelle des identifiants",
value: _chkUsernameManuel,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkUsernameManuel = value!;
});
},
),
],
),
),
],
),
const SizedBox(height: 25),

View File

@@ -171,9 +171,10 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
user: user,
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser) async {
onSubmit: (updatedUser, {String? password}) async {
try {
// Sauvegarder les modifications de l'utilisateur
// Note: password est ignoré ici car l'utilisateur normal ne peut pas changer son mot de passe
await userRepository.updateUser(updatedUser);
if (context.mounted) {

View File

@@ -1,199 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement amélioré qui affiche une barre de progression
/// avec un effet de flou sur l'arrière-plan et un message détaillé sur l'étape en cours
class LoadingProgressOverlay extends StatefulWidget {
final String? message;
final double progress;
final String? stepDescription;
final Color backgroundColor;
final Color progressColor;
final Color textColor;
final double blurAmount;
final bool showPercentage;
const LoadingProgressOverlay({
super.key,
this.message,
required this.progress,
this.stepDescription,
this.backgroundColor = Colors.black54,
this.progressColor = Colors.white,
this.textColor = Colors.white,
this.blurAmount = 5.0,
this.showPercentage = true,
});
@override
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
}
class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_progressAnimation = Tween<double>(begin: 0, end: widget.progress).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void didUpdateWidget(LoadingProgressOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_progressAnimation = Tween<double>(
begin: oldWidget.progress,
end: widget.progress,
).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount, sigmaY: widget.blurAmount),
child: Container(
color: widget.backgroundColor,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black45,
blurRadius: 15,
spreadRadius: 5,
offset: Offset(0, 5),
),
],
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.5,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message != null) ...[
Text(
widget.message!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return Column(
children: [
LinearProgressIndicator(
value: _progressAnimation.value,
backgroundColor:
widget.progressColor.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
widget.progressColor),
minHeight: 15,
borderRadius: BorderRadius.circular(8),
),
if (widget.showPercentage) ...[
const SizedBox(height: 8),
Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 1.2,
),
),
],
],
);
},
),
if (widget.stepDescription != null) ...[
const SizedBox(height: 16),
Text(
widget.stepDescription!,
style: TextStyle(
fontSize: 16,
color: widget.textColor.withOpacity(0.9),
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec progression
class LoadingProgressOverlayUtils {
/// Méthode pour afficher l'overlay de chargement avec progression
static OverlayEntry show({
required BuildContext context,
String? message,
double progress = 0.0,
String? stepDescription,
double blurAmount = 5.0,
bool showPercentage = true,
}) {
final overlayEntry = OverlayEntry(
builder: (context) => LoadingProgressOverlay(
message: message,
progress: progress,
stepDescription: stepDescription,
blurAmount: blurAmount,
showPercentage: showPercentage,
),
);
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Méthode pour mettre à jour l'overlay existant
static void update({
required OverlayEntry overlayEntry,
String? message,
required double progress,
String? stepDescription,
}) {
overlayEntry.markNeedsBuild();
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement moderne avec spinner circulaire
/// Affiche un spinner animé avec fond flou et message optionnel
class LoadingSpinOverlay extends StatefulWidget {
final String? message;
final Color backgroundColor;
final Color spinnerColor;
final Color textColor;
final double blurAmount;
final double spinnerSize;
final bool showCard;
const LoadingSpinOverlay({
super.key,
this.message,
this.backgroundColor = Colors.black54,
this.spinnerColor = Colors.blue,
this.textColor = Colors.white,
this.blurAmount = 8.0,
this.spinnerSize = 50.0,
this.showCard = true,
});
@override
State<LoadingSpinOverlay> createState() => _LoadingSpinOverlayState();
}
class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _rotationController;
late Animation<double> _fadeAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_rotationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 2 * 3.14159,
).animate(CurvedAnimation(
parent: _rotationController,
curve: Curves.linear,
));
_fadeController.forward();
_rotationController.repeat();
}
@override
void dispose() {
_fadeController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount,
sigmaY: widget.blurAmount,
),
child: Container(
color: widget.backgroundColor,
child: Center(
child: widget.showCard
? _buildCardContent()
: _buildSimpleContent(),
),
),
),
);
}
Widget _buildCardContent() {
return Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(32),
constraints: const BoxConstraints(
maxWidth: 280,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.92), // Semi-transparent
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 20,
spreadRadius: 2,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Spinner simple de Flutter
SizedBox(
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
if (widget.message != null) ...[
const SizedBox(height: 24),
Text(
widget.message!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildSimpleContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
if (widget.message != null) ...[
const SizedBox(height: 20),
Text(
widget.message!,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
],
],
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec spinner
class LoadingSpinOverlayUtils {
static OverlayEntry? _currentOverlay;
/// Affiche l'overlay de chargement avec spinner
static OverlayEntry show({
required BuildContext context,
String? message,
double blurAmount = 8.0,
bool showCard = true,
Color? spinnerColor,
}) {
// Fermer l'overlay existant s'il y en a un
hide();
final theme = Theme.of(context);
final overlayEntry = OverlayEntry(
builder: (context) => LoadingSpinOverlay(
message: message,
blurAmount: blurAmount,
showCard: showCard,
spinnerColor: spinnerColor ?? theme.colorScheme.primary,
),
);
_currentOverlay = overlayEntry;
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Met à jour le message de l'overlay existant
static void updateMessage({
required OverlayEntry overlayEntry,
String? message,
}) {
overlayEntry.markNeedsBuild();
}
/// Cache l'overlay de chargement
static void hide() {
_currentOverlay?.remove();
_currentOverlay = null;
}
/// Cache un overlay spécifique
static void hideSpecific(OverlayEntry? overlayEntry) {
overlayEntry?.remove();
if (_currentOverlay == overlayEntry) {
_currentOverlay = null;
}
}
}

View File

@@ -8,6 +8,7 @@ class MembreRowWidget extends StatelessWidget {
final Function(MembreModel)? onResetPassword;
final bool isAlternate;
final VoidCallback? onTap;
final bool isMobile;
const MembreRowWidget({
super.key,
@@ -17,6 +18,7 @@ class MembreRowWidget extends StatelessWidget {
this.onResetPassword,
this.isAlternate = false,
this.onTap,
this.isMobile = false,
});
@override
@@ -37,20 +39,29 @@ class MembreRowWidget extends StatelessWidget {
),
child: Row(
children: [
// ... existing row content ...
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
// ID - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Identifiant (username) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
membre.username ?? '',
style: theme.textTheme.bodyMedium,
),
),
),
// Prénom
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
membre.firstName ?? '',
style: theme.textTheme.bodyMedium,
@@ -59,47 +70,44 @@ class MembreRowWidget extends StatelessWidget {
// Nom
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
membre.name ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Email
Expanded(
flex: 3,
child: Text(
membre.email ?? '',
style: theme.textTheme.bodyMedium,
// Email - masqué en mobile
if (!isMobile)
Expanded(
flex: 3,
child: Text(
membre.email ?? '',
style: theme.textTheme.bodyMedium,
),
),
),
// Rôle
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.role),
style: theme.textTheme.bodyMedium,
// Rôle - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.role),
style: theme.textTheme.bodyMedium,
),
),
),
// Statut
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: _getStatusColor(membre.isActive),
borderRadius: BorderRadius.circular(12.0),
),
child: Text(
membre.isActive == true ? 'Actif' : 'Inactif',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
child: Center(
child: Tooltip(
message: membre.isActive == true ? 'Actif' : 'Inactif',
child: Icon(
membre.isActive == true ? Icons.check_circle : Icons.cancel,
color: membre.isActive == true ? Colors.green : Colors.red,
size: 24,
),
textAlign: TextAlign.center,
),
),
),
@@ -107,21 +115,21 @@ class MembreRowWidget extends StatelessWidget {
// Actions
if (onEdit != null || onDelete != null || onResetPassword != null)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton reset password (uniquement pour les membres actifs)
if (onResetPassword != null && membre.isActive == true)
IconButton(
icon: const Icon(Icons.lock_reset, size: 22),
icon: Icon(Icons.lock_reset, size: isMobile ? 20 : 22),
onPressed: () => onResetPassword!(membre),
tooltip: 'Réinitialiser le mot de passe',
color: theme.colorScheme.primary,
),
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete, size: 22),
icon: Icon(Icons.delete, size: isMobile ? 20 : 22),
onPressed: () => onDelete!(membre),
tooltip: 'Supprimer',
color: theme.colorScheme.error,

View File

@@ -32,6 +32,8 @@ class MembreTableWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 768;
return Container(
height: height,
@@ -61,21 +63,35 @@ class MembreTableWidget extends StatelessWidget {
),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// ID - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Identifiant (username) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
'Identifiant',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Prénom (firstName)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Prénom',
style: theme.textTheme.titleSmall?.copyWith(
@@ -87,7 +103,7 @@ class MembreTableWidget extends StatelessWidget {
// Nom (name)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Nom',
style: theme.textTheme.titleSmall?.copyWith(
@@ -97,29 +113,31 @@ class MembreTableWidget extends StatelessWidget {
),
),
// Email
Expanded(
flex: 3,
child: Text(
'Email',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// Email - masqué en mobile
if (!isMobile)
Expanded(
flex: 3,
child: Text(
'Email',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// Rôle (fkRole) - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Statut
Expanded(
@@ -136,7 +154,7 @@ class MembreTableWidget extends StatelessWidget {
// Actions (si onEdit, onDelete ou onResetPassword sont fournis)
if (onEdit != null || onDelete != null || onResetPassword != null)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Actions',
style: theme.textTheme.titleSmall?.copyWith(
@@ -152,14 +170,14 @@ class MembreTableWidget extends StatelessWidget {
// Corps du tableau
Expanded(
child: _buildTableContent(context),
child: _buildTableContent(context, isMobile),
),
],
),
);
}
Widget _buildTableContent(BuildContext context) {
Widget _buildTableContent(BuildContext context, bool isMobile) {
// Afficher un indicateur de chargement si isLoading est true
if (isLoading) {
return const Center(child: CircularProgressIndicator());
@@ -193,6 +211,7 @@ class MembreTableWidget extends StatelessWidget {
onResetPassword: onResetPassword,
isAlternate: index % 2 == 1,
onTap: onEdit != null ? () => onEdit!(membre) : null,
isMobile: isMobile,
);
},
);

View File

@@ -1,7 +1,10 @@
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 {
@@ -10,6 +13,8 @@ class UserForm extends StatefulWidget {
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,
@@ -18,6 +23,8 @@ class UserForm extends StatefulWidget {
this.readOnly = false,
this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
this.amicale,
this.isAdmin = false,
});
@override
@@ -37,11 +44,19 @@ class _UserFormState extends State<UserForm> {
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() {
@@ -64,11 +79,34 @@ class _UserFormState extends State<UserForm> {
_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();
@@ -78,6 +116,7 @@ class _UserFormState extends State<UserForm> {
_emailController.dispose();
_dateNaissanceController.dispose();
_dateEmbaucheController.dispose();
_passwordController.dispose();
super.dispose();
}
@@ -98,13 +137,49 @@ class _UserFormState extends State<UserForm> {
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
try {
// Afficher le sélecteur de date sans spécifier de locale
// 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: DateTime.now(), // Toujours utiliser la date actuelle
initialDate: initialDate,
firstDate: DateTime(1900),
lastDate: DateTime.now(),
// Ne pas spécifier de locale pour éviter les problèmes
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) {
@@ -141,30 +216,247 @@ class _UserFormState extends State<UserForm> {
}
}
// Nettoyer une chaîne pour l'username
String _cleanString(String input) {
return input.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]'), ''); // Garder seulement lettres et chiffres
}
// 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 l'algorithme spécifié
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 (uniquement ceux autorisés: ., _, -)
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 10 caractères
while (username.length < 10) {
username += _random.nextInt(10).toString();
}
// S'assurer que l'username ne contient que des caractères autorisés (a-z, 0-9, ., -, _)
// Normalement déjà le cas avec notre algorithme, mais au cas où
username = username.toLowerCase().replaceAll(RegExp(r'[^a-z0-9._-]'), '');
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 règles
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
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le mot de passe ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans le mot de passe
if (trimmedValue.contains(' ')) {
return "Le mot de passe ne doit pas contenir d'espaces";
}
// Vérifier la longueur
if (trimmedValue.length < 12) {
return "Le mot de passe doit contenir au moins 12 caractères";
}
if (trimmedValue.length > 16) {
return "Le mot de passe ne doit pas dépasser 16 caractères";
}
// Vérifier qu'il n'est pas égal au username (après trim des deux)
if (trimmedValue == _usernameController.text.trim()) {
return "Le mot de passe ne doit pas être identique au nom d'utilisateur";
}
// Vérifier la présence d'au moins une minuscule
if (!trimmedValue.contains(RegExp(r'[a-z]'))) {
return "Le mot de passe doit contenir au moins une lettre minuscule";
}
// Vérifier la présence d'au moins une majuscule
if (!trimmedValue.contains(RegExp(r'[A-Z]'))) {
return "Le mot de passe doit contenir au moins une lettre majuscule";
}
// Vérifier la présence d'au moins un chiffre
if (!trimmedValue.contains(RegExp(r'[0-9]'))) {
return "Le mot de passe doit contenir au moins un chiffre";
}
// Vérifier la présence d'au moins un caractère spécial
if (!trimmedValue.contains(RegExp(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]'))) {
return "Le mot de passe doit contenir au moins un caractère spécial (!@#\$%^&*()_+-=[]{}|;:,.<>?)";
}
return null;
}
// Générer un mot de passe aléatoire respectant les règles
String _generatePassword() {
const String lowercase = 'abcdefghijklmnopqrstuvwxyz';
const String uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const String digits = '0123456789';
const String special = '!@#\$%^&*()_+-=[]{}|;:,.<>?';
// Longueur aléatoire entre 12 et 16
final length = 12 + _random.nextInt(5);
// S'assurer d'avoir au moins un caractère de chaque type
List<String> password = [];
password.add(lowercase[_random.nextInt(lowercase.length)]);
password.add(uppercase[_random.nextInt(uppercase.length)]);
password.add(digits[_random.nextInt(digits.length)]);
password.add(special[_random.nextInt(special.length)]);
// Compléter avec des caractères aléatoires
const String allChars = lowercase + uppercase + digits + special;
for (int i = password.length; i < length; i++) {
password.add(allChars[_random.nextInt(allChars.length)]);
}
// Mélanger les caractères
password.shuffle(_random);
return password.join('');
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
final password = _passwordController.text.trim();
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,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
username: _usernameController.text.trim(), // Appliquer trim
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,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
username: _usernameController.text.trim(), // Appliquer trim
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,
@@ -180,90 +472,36 @@ class _UserFormState extends State<UserForm> {
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: [
// Ligne 1: Username et Email (si écran large)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !widget.allowUsernameEdit, // Utiliser le paramètre
prefixIcon: Icons.account_circle,
isRequired: widget.allowUsernameEdit,
validator: widget.allowUsernameEdit
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
return null;
}
: null,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true, // Email toujours obligatoire
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;
},
),
),
],
)
else ...[
// Version mobile: Username seul
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !widget.allowUsernameEdit, // Utiliser le paramètre
prefixIcon: Icons.account_circle,
isRequired: widget.allowUsernameEdit, // Obligatoire si éditable
validator: widget.allowUsernameEdit
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
return null;
}
: null,
),
const SizedBox(height: 16),
// Email seul en mobile
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true, // Email toujours obligatoire
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;
},
),
],
// 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)
@@ -378,7 +616,7 @@ class _UserFormState extends State<UserForm> {
const SizedBox(height: 16),
],
// Ligne 3: Téléphones (fixe et mobile)
// Ligne 2: Téléphones (fixe et mobile)
if (isWideScreen)
Row(
children: [
@@ -458,6 +696,224 @@ class _UserFormState extends State<UserForm> {
),
],
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
? "Min. 10 caractères (a-z, 0-9, . - _)"
: null,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
}
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"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
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
? "Min. 10 caractères (a-z, 0-9, . - _)"
: null,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
}
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"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
validator: _validatePassword,
),
const SizedBox(height: 16),
],
],
const SizedBox(height: 16),
],
// Ligne 4: Dates (naissance et embauche)
if (isWideScreen)

View File

@@ -1,16 +1,19 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class UserFormDialog extends StatefulWidget {
final UserModel? user;
final String title;
final bool readOnly;
final Function(UserModel)? onSubmit;
final Function(UserModel, {String? password})? onSubmit; // Modifié pour inclure le mot de passe
final bool showRoleSelector;
final List<RoleOption>? availableRoles;
final bool showActiveCheckbox;
final bool allowUsernameEdit;
final AmicaleModel? amicale; // Nouveau paramètre
final bool isAdmin; // Nouveau paramètre
const UserFormDialog({
super.key,
@@ -22,6 +25,8 @@ class UserFormDialog extends StatefulWidget {
this.availableRoles,
this.showActiveCheckbox = false,
this.allowUsernameEdit = false,
this.amicale,
this.isAdmin = false,
});
@override
@@ -55,6 +60,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
var finalUser = userData;
@@ -70,7 +76,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser);
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
}
}
@@ -200,6 +206,8 @@ class _UserFormDialogState extends State<UserFormDialog> {
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
amicale: widget.amicale, // Passer l'amicale
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback
),
],