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:
@@ -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),
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
229
app/lib/presentation/widgets/loading_spin_overlay.dart
Normal file
229
app/lib/presentation/widgets/loading_spin_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user