membre add

This commit is contained in:
d6soft
2025-06-11 09:27:25 +02:00
parent f3f1a9c5e8
commit 4244b961fd
40 changed files with 144003 additions and 143144 deletions

View File

@@ -5,11 +5,13 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/membre_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@@ -93,30 +95,86 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
}
void _handleEditMembre(MembreModel membre) {
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Modifier le membre',
user: membre.toUserModel(),
showRoleSelector: true,
showActiveCheckbox: true, // Activer la checkbox
allowUsernameEdit: true, // Permettre l'édition du username
// allowSectNameEdit sera automatiquement true via UserForm
availableRoles: const [
RoleOption(
value: 1,
label: 'Membre',
description: 'Peut consulter et distribuer dans ses secteurs',
),
RoleOption(
value: 2,
label: 'Administrateur',
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (updatedUser) 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);
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');
if (mounted) {
ApiException.showError(context, e);
}
}
},
),
);
}
void _handleDeleteMembre(MembreModel membre) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier le membre'),
content: Text('Voulez-vous modifier le membre ${membre.firstName} ${membre.name} ?'),
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer le membre ${membre.firstName} ${membre.name} ?\n\nCette action est irréversible.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.of(context).pop();
// TODO: Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditMembrePage(
// membre: membre,
// membreRepository: widget.membreRepository,
// ),
// ),
// );
try {
// Utiliser la méthode qui passe par l'API
final success = await widget.membreRepository.deleteMembre(membre.id);
if (success && mounted) {
ApiException.showSuccess(context, 'Membre ${membre.firstName} ${membre.name} supprimé avec succès');
} else if (!success && mounted) {
ApiException.showError(context, Exception('Erreur lors de la suppression'));
}
} catch (e) {
if (mounted) {
ApiException.showError(context, e);
}
}
},
child: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
@@ -126,15 +184,82 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
void _handleAddMembre() {
if (_currentUser?.fkEntite == null) return;
// TODO: Naviguer vers la page d'ajout de membre
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => AddMembrePage(
// amicaleId: _currentUser!.fkEntite!,
// membreRepository: widget.membreRepository,
// ),
// ),
// );
// Créer un UserModel vide avec les valeurs par défaut
final newUser = UserModel(
id: 0, // ID temporaire pour nouveau membre
username: '',
firstName: '',
name: '',
sectName: '',
phone: '',
mobile: '',
email: '',
fkTitre: 1, // Par défaut M.
fkEntite: _currentUser!.fkEntite!, // Association à l'amicale courante
role: 1, // Par défaut membre
isActive: true, // Par défaut actif
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Ajouter un nouveau membre',
user: newUser,
showRoleSelector: true,
showActiveCheckbox: true,
allowUsernameEdit: true,
availableRoles: const [
RoleOption(
value: 1,
label: 'Membre',
description: 'Peut consulter et distribuer dans ses secteurs',
),
RoleOption(
value: 2,
label: 'Administrateur',
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (newUserData) async {
try {
// Créer un nouveau MembreModel directement
final newMembre = MembreModel(
id: 0, // L'API assignera un vrai ID
username: newUserData.username,
firstName: newUserData.firstName,
name: newUserData.name,
sectName: newUserData.sectName,
phone: newUserData.phone,
mobile: newUserData.mobile,
email: newUserData.email,
fkTitre: newUserData.fkTitre,
fkEntite: newUserData.fkEntite!,
role: newUserData.role,
isActive: newUserData.isActive,
dateNaissance: newUserData.dateNaissance,
dateEmbauche: newUserData.dateEmbauche,
createdAt: DateTime.now(),
);
final createdMembre = await widget.membreRepository.createMembre(newMembre);
if (createdMembre != null && mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès');
} else if (mounted) {
ApiException.showError(context, Exception('Erreur lors de la création du membre'));
}
} catch (e) {
debugPrint('❌ Erreur création membre: $e');
if (mounted) {
ApiException.showError(context, e);
}
}
},
),
);
}
@override
@@ -328,7 +453,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
child: MembreTableWidget(
membres: membres,
onEdit: _handleEditMembre,
onDelete: null, // Géré par l'admin principal
onDelete: _handleDeleteMembre,
membreRepository: widget.membreRepository,
),
),

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
@@ -117,10 +118,32 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
icon: const Icon(Icons.person),
tooltip: 'Mon compte',
onPressed: () {
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
// Afficher la boîte de dialogue UserForm avec l'utilisateur actuel
final user = userRepository.currentUser;
if (user != null) {
ProfileDialog.show(context, user);
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Mon compte',
user: user,
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser) async {
try {
// Sauvegarder les modifications de l'utilisateur
await userRepository.updateUser(updatedUser);
if (context.mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Profil mis à jour');
}
} catch (e) {
debugPrint('❌ Erreur mise à jour de votre profil: $e');
ApiException.showError(context, e);
}
},
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche les informations sur l'environnement actuel
@@ -8,13 +7,13 @@ class EnvironmentInfoWidget extends StatelessWidget {
final bool showInDialog;
const EnvironmentInfoWidget({
Key? key,
super.key,
this.showInDialog = false,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
final apiService = Provider.of<ApiService>(context, listen: false);
final apiService = ApiService.instance;
final environment = apiService.getCurrentEnvironment();
final apiUrl = apiService.getCurrentApiUrl();
final appIdentifier = apiService.getCurrentAppIdentifier();
@@ -27,9 +26,7 @@ class EnvironmentInfoWidget extends StatelessWidget {
children: [
Text(
'🌍 Environnement GeoSector',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _getEnvironmentColor(environment)),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold, color: _getEnvironmentColor(environment)),
),
const SizedBox(height: 16),
_buildInfoRow(context, 'Environnement', environment),
@@ -70,10 +67,7 @@ class EnvironmentInfoWidget extends StatelessWidget {
width: 120,
child: Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Expanded(

View File

@@ -6,6 +6,7 @@ class MembreRowWidget extends StatelessWidget {
final Function(MembreModel)? onEdit;
final Function(MembreModel)? onDelete;
final bool isAlternate;
final VoidCallback? onTap;
const MembreRowWidget({
super.key,
@@ -13,6 +14,7 @@ class MembreRowWidget extends StatelessWidget {
this.onEdit,
this.onDelete,
this.isAlternate = false,
this.onTap,
});
@override
@@ -20,43 +22,45 @@ class MembreRowWidget extends StatelessWidget {
final theme = Theme.of(context);
// Couleur de fond alternée
final backgroundColor = isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface;
final backgroundColor = isAlternate ? theme.colorScheme.primary.withValues(alpha: 0.05) : Colors.transparent;
return InkWell(
onTap: () => _showMembreDetails(context),
// Envelopper le contenu dans un InkWell
onTap: onTap, // Utiliser le callback onTap
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.15),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: backgroundColor,
),
child: Row(
children: [
// ... existing row content ...
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString(),
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Prénom (firstName)
// Prénom
Expanded(
flex: 2,
child: Text(
membre.firstName ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Nom (name)
// Nom
Expanded(
flex: 2,
child: Text(
membre.name ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
@@ -64,13 +68,12 @@ class MembreRowWidget extends StatelessWidget {
Expanded(
flex: 3,
child: Text(
membre.email,
membre.email ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Rôle (role au lieu de fkRole)
// Rôle
Expanded(
flex: 1,
child: Text(
@@ -79,22 +82,19 @@ class MembreRowWidget extends StatelessWidget {
),
),
// Statut (isActive au lieu de chkActive)
// Statut
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: membre.isActive ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: membre.isActive ? Colors.green.withOpacity(0.3) : Colors.red.withOpacity(0.3),
),
color: _getStatusColor(membre.isActive),
borderRadius: BorderRadius.circular(12.0),
),
child: Text(
membre.isActive ? 'Actif' : 'Inactif',
membre.isActive == true ? 'Actif' : 'Inactif',
style: theme.textTheme.bodySmall?.copyWith(
color: membre.isActive ? Colors.green[700] : Colors.red[700],
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
@@ -109,43 +109,12 @@ class MembreRowWidget extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton Edit
if (onEdit != null)
IconButton(
icon: Icon(
Icons.edit,
size: 20,
color: theme.colorScheme.primary,
),
onPressed: () => onEdit!(membre),
tooltip: 'Modifier',
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
// Espacement entre les boutons
if (onEdit != null && onDelete != null) const SizedBox(width: 8),
// Bouton Delete
if (onDelete != null)
IconButton(
icon: Icon(
Icons.delete,
size: 20,
color: theme.colorScheme.error,
),
icon: const Icon(Icons.delete, size: 22),
onPressed: () => onDelete!(membre),
tooltip: 'Supprimer',
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
color: theme.colorScheme.error,
),
],
),
@@ -212,14 +181,18 @@ class MembreRowWidget extends StatelessWidget {
);
}
Color _getStatusColor(bool? isActive) {
return isActive == true ? Colors.green : Colors.red;
}
// Méthode pour convertir l'ID de rôle en nom lisible
String _getRoleName(int roleId) {
switch (roleId) {
case 1:
return 'User';
return 'Membre';
case 2:
return 'Admin';
case 3:
case 9:
return 'Super';
default:
return roleId.toString();

View File

@@ -48,10 +48,15 @@ class MembreTableWidget extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête du tableau
// En-tête du tableau avec fond grisé
if (showHeader)
Padding(
padding: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
Container(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
// ID
@@ -184,6 +189,7 @@ class MembreTableWidget extends StatelessWidget {
onEdit: onEdit,
onDelete: onDelete,
isAlternate: index % 2 == 1,
onTap: onEdit != null ? () => onEdit!(membre) : null,
);
},
);

View File

@@ -1,268 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class ProfileDialog extends StatefulWidget {
final UserModel user;
const ProfileDialog({
Key? key,
required this.user,
}) : super(key: key);
/// Méthode statique pour afficher la boîte de dialogue
static Future<bool?> show(BuildContext context, UserModel user) {
return showDialog<bool>(
context: context,
builder: (context) => ProfileDialog(user: user),
);
}
@override
State<ProfileDialog> createState() => _ProfileDialogState();
}
class _ProfileDialogState extends State<ProfileDialog> {
final _formKey = GlobalKey<FormState>();
late UserModel _user;
bool _isLoading = false;
@override
void initState() {
super.initState();
_user = widget.user;
}
// Fonction pour capitaliser la première lettre de chaque mot
String _capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text.split(' ').map((word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}).join(' ');
}
// Fonction pour mettre en majuscule
String _toUpperCase(String text) {
return text.toUpperCase();
}
// Fonction pour valider et soumettre le formulaire
Future<void> _saveProfile(UserModel updatedUser) async {
// Validation supplémentaire
if (!_validateUser(updatedUser)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Formatage des données
final formattedUser = updatedUser.copyWith(
name: _toUpperCase(updatedUser.name ?? ''),
firstName: _capitalizeFirstLetter(updatedUser.firstName ?? ''),
);
// Sauvegarde de l'utilisateur
await userRepository.saveUser(formattedUser);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil mis à jour avec succès'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true); // Fermer la modale avec succès
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour du profil: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// Validation supplémentaire
bool _validateUser(UserModel user) {
// Vérifier que l'email est valide
if (user.email.isEmpty ||
!user.email.contains('@') ||
!user.email.contains('.')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez entrer une adresse email valide'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le nom ou le sectName est renseigné
if ((user.name == null || user.name!.isEmpty) &&
(user.sectName == null || user.sectName!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le nom ou le nom du secteur doit être renseigné'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone fixe est valide s'il est renseigné
if (user.phone != null &&
user.phone!.isNotEmpty &&
user.phone!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone fixe doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone mobile est valide s'il est renseigné
if (user.mobile != null &&
user.mobile!.isNotEmpty &&
user.mobile!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone mobile doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Mon compte',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(),
const SizedBox(height: 10),
// Formulaire
Expanded(
child: SingleChildScrollView(
child: UserForm(
user: _user,
onSubmit: _saveProfile,
),
),
),
// Boutons
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: const Text('Fermer'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _isLoading
? null
: () {
// Appeler directement la méthode onSubmit du UserForm
// qui va déclencher la validation et la soumission
_saveProfile(_user);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}

View File

@@ -8,13 +8,17 @@ class UserForm extends StatefulWidget {
final UserModel? user;
final Function(UserModel)? onSubmit;
final bool readOnly;
final bool allowUsernameEdit;
final bool allowSectNameEdit;
const UserForm({
Key? key,
super.key,
this.user,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
});
@override
State<UserForm> createState() => _UserFormState();
@@ -27,6 +31,7 @@ class _UserFormState extends State<UserForm> {
late final TextEditingController _usernameController;
late final TextEditingController _firstNameController;
late final TextEditingController _nameController;
late final TextEditingController _sectNameController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
@@ -47,6 +52,7 @@ class _UserFormState extends State<UserForm> {
_usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? '');
_sectNameController = TextEditingController(text: user?.sectName ?? '');
_phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
@@ -54,15 +60,9 @@ class _UserFormState extends State<UserForm> {
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController(
text: _dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: '');
_dateNaissanceController = TextEditingController(text: _dateNaissance != null ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) : '');
_dateEmbaucheController = TextEditingController(
text: _dateEmbauche != null
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
: '');
_dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : '');
_fkTitre = user?.fkTitre ?? 1;
}
@@ -72,6 +72,7 @@ class _UserFormState extends State<UserForm> {
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
_sectNameController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
@@ -80,6 +81,19 @@ class _UserFormState extends State<UserForm> {
super.dispose();
}
// Validation conditionnelle pour name/sectName
String? _validateNameOrSectName(String? value, bool isNameField) {
final nameValue = _nameController.text.trim();
final sectNameValue = _sectNameController.text.trim();
// Si les deux sont vides
if (nameValue.isEmpty && sectNameValue.isEmpty) {
return isNameField ? "Veuillez renseigner soit le nom soit le nom de tournée" : "Veuillez renseigner soit le nom de tournée soit le nom";
}
return null; // Validation OK si au moins un des deux est rempli
}
// Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
@@ -98,12 +112,10 @@ class _UserFormState extends State<UserForm> {
// Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) {
_dateNaissance = picked;
_dateNaissanceController.text =
DateFormat('dd/MM/yyyy').format(picked);
_dateNaissanceController.text = DateFormat('dd/MM/yyyy').format(picked);
} else {
_dateEmbauche = picked;
_dateEmbaucheController.text =
DateFormat('dd/MM/yyyy').format(picked);
_dateEmbaucheController.text = DateFormat('dd/MM/yyyy').format(picked);
}
});
}
@@ -111,7 +123,7 @@ class _UserFormState extends State<UserForm> {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
@@ -121,7 +133,7 @@ class _UserFormState extends State<UserForm> {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
@@ -129,11 +141,14 @@ class _UserFormState extends State<UserForm> {
}
}
void _submitForm() {
// Méthode publique pour valider et récupérer l'utilisateur
UserModel? validateAndGetUser() {
if (_formKey.currentState!.validate()) {
final user = widget.user?.copyWith(
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,
@@ -142,42 +157,113 @@ class _UserFormState extends State<UserForm> {
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0, // Sera remplacé par l'API
id: 0,
username: _usernameController.text,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1, // Valeur par défaut
role: 1,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
if (widget.onSubmit != null) {
widget.onSubmit!(user);
}
}
return null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isWideScreen = MediaQuery.of(context).size.width > 900;
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom d'utilisateur (en lecture seule)
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: true, // Toujours en lecture seule
prefixIcon: Icons.account_circle,
),
// 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;
},
),
],
const SizedBox(height: 16),
// Titre (M. ou Mme)
@@ -188,7 +274,7 @@ class _UserFormState extends State<UserForm> {
"Titre",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -225,115 +311,210 @@ class _UserFormState extends State<UserForm> {
),
const SizedBox(height: 16),
// Prénom
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le prénom";
}
return null;
},
),
const SizedBox(height: 16),
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone fixe
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone mobile
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
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),
// Date de naissance
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
// Ligne 2: Prénom et Nom
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
),
],
)
else ...[
// Version mobile: Prénom et nom séparés
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
),
const SizedBox(height: 16),
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
],
const SizedBox(height: 16),
// Date d'embauche
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
// Ligne 2.5: Nom de tournée (sectName) - uniquement si éditable
if (widget.allowSectNameEdit) ...[
CustomTextField(
controller: _sectNameController,
label: "Nom de tournée",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, false),
onChanged: (value) {
// Revalider name quand sectName change
_formKey.currentState?.validate();
},
hintText: "Nom utilisé pour identifier la tournée",
),
),
// Espace en bas du formulaire
const SizedBox(height: 16),
],
// Ligne 3: Téléphones (fixe et mobile)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
],
)
else ...[
// Version mobile: Téléphones séparés
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
],
const SizedBox(height: 16),
// Ligne 4: Dates (naissance et embauche)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
],
)
else ...[
// Version mobile: Dates séparées
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
],
const SizedBox(height: 16),
],
),
@@ -360,7 +541,7 @@ class _UserFormState extends State<UserForm> {
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
@@ -368,3 +549,6 @@ class _UserFormState extends State<UserForm> {
);
}
}
// Exporter la classe State pour pouvoir l'utiliser avec GlobalKey
typedef UserFormState = _UserFormState;

View File

@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/user_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 bool showRoleSelector;
final List<RoleOption>? availableRoles;
final bool showActiveCheckbox;
final bool allowUsernameEdit;
const UserFormDialog({
super.key,
this.user,
required this.title,
this.readOnly = false,
this.onSubmit,
this.showRoleSelector = false,
this.availableRoles,
this.showActiveCheckbox = false,
this.allowUsernameEdit = false,
});
@override
State<UserFormDialog> createState() => _UserFormDialogState();
}
class RoleOption {
final int value;
final String label;
final String description;
const RoleOption({
required this.value,
required this.label,
required this.description,
});
}
class _UserFormDialogState extends State<UserFormDialog> {
final GlobalKey<UserFormState> _userFormKey = GlobalKey<UserFormState>();
int? _selectedRole;
bool? _isActive;
@override
void initState() {
super.initState();
_selectedRole = widget.user?.role;
_isActive = widget.user?.isActive ?? true; // Initialiser le statut actif
}
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
if (userData != null) {
var finalUser = userData;
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: MediaQuery.of(context).size.width * 0.5,
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const Divider(),
// Contenu du formulaire
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélecteur de rôle (si activé)
if (widget.showRoleSelector && widget.availableRoles != null) ...[
Text(
'Rôle dans l\'amicale',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: widget.availableRoles!.map((role) {
return RadioListTile<int>(
title: Text(role.label),
subtitle: Text(
role.description,
style: theme.textTheme.bodySmall,
),
value: role.value,
groupValue: _selectedRole,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
);
}).toList(),
),
),
const SizedBox(height: 16),
],
// Checkbox Statut Actif (si activé)
if (widget.showActiveCheckbox) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: CheckboxListTile(
title: Text(
'Compte actif',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
_isActive == true ? 'Le membre peut se connecter et utiliser l\'application' : 'Le membre ne peut pas se connecter',
style: theme.textTheme.bodySmall,
),
value: _isActive,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_isActive = value ?? true;
});
},
activeColor: theme.colorScheme.primary,
controlAffinity: ListTileControlAffinity.leading,
),
),
const SizedBox(height: 16),
],
// Formulaire utilisateur avec la clé
UserForm(
key: _userFormKey,
user: widget.user,
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
onSubmit: null, // Pas besoin de callback
),
],
),
),
),
const SizedBox(height: 24),
// Footer avec boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}