Unifie la modale utilisateur pour création/édition admin et gestionnaire, fiabilise la saisie/normalisation (téléphone, nom/prénom) et corrige la mise à jour backend pour accepter le rattachement relais sans erreur 400. Co-authored-by: Cursor <cursoragent@cursor.com>
610 lines
18 KiB
Dart
610 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:p_tits_pas/models/relais_model.dart';
|
||
import 'package:p_tits_pas/models/user.dart';
|
||
import 'package:p_tits_pas/services/relais_service.dart';
|
||
import 'package:p_tits_pas/services/user_service.dart';
|
||
|
||
class AdminUserFormDialog extends StatefulWidget {
|
||
final AppUser? initialUser;
|
||
final bool withRelais;
|
||
final bool adminMode;
|
||
|
||
const AdminUserFormDialog({
|
||
super.key,
|
||
this.initialUser,
|
||
this.withRelais = true,
|
||
this.adminMode = false,
|
||
});
|
||
|
||
@override
|
||
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
||
}
|
||
|
||
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _nomController = TextEditingController();
|
||
final _prenomController = TextEditingController();
|
||
final _emailController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
final _telephoneController = TextEditingController();
|
||
final _passwordToggleFocusNode =
|
||
FocusNode(skipTraversal: true, canRequestFocus: false);
|
||
|
||
bool _isSubmitting = false;
|
||
bool _obscurePassword = true;
|
||
bool _isLoadingRelais = true;
|
||
List<RelaisModel> _relais = [];
|
||
String? _selectedRelaisId;
|
||
bool get _isEditMode => widget.initialUser != null;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final user = widget.initialUser;
|
||
if (user != null) {
|
||
_nomController.text = user.nom ?? '';
|
||
_prenomController.text = user.prenom ?? '';
|
||
_emailController.text = user.email;
|
||
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
||
// En édition, on ne préremplit jamais le mot de passe.
|
||
_passwordController.clear();
|
||
final initialRelaisId = user.relaisId?.trim();
|
||
_selectedRelaisId =
|
||
(initialRelaisId == null || initialRelaisId.isEmpty)
|
||
? null
|
||
: initialRelaisId;
|
||
}
|
||
if (widget.withRelais) {
|
||
_loadRelais();
|
||
} else {
|
||
_isLoadingRelais = false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nomController.dispose();
|
||
_prenomController.dispose();
|
||
_emailController.dispose();
|
||
_passwordController.dispose();
|
||
_telephoneController.dispose();
|
||
_passwordToggleFocusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadRelais() async {
|
||
try {
|
||
final list = await RelaisService.getRelais();
|
||
if (!mounted) return;
|
||
final uniqueById = <String, RelaisModel>{};
|
||
for (final relais in list) {
|
||
uniqueById[relais.id] = relais;
|
||
}
|
||
|
||
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
||
if (_selectedRelaisId != null &&
|
||
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
||
final selected = uniqueById[_selectedRelaisId!];
|
||
if (selected != null) {
|
||
filtered.add(selected);
|
||
} else {
|
||
_selectedRelaisId = null;
|
||
}
|
||
}
|
||
|
||
setState(() {
|
||
_relais = filtered;
|
||
_isLoadingRelais = false;
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_selectedRelaisId = null;
|
||
_relais = [];
|
||
_isLoadingRelais = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
String? _required(String? value, String field) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return '$field est requis';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateEmail(String? value) {
|
||
final base = _required(value, 'Email');
|
||
if (base != null) return base;
|
||
final email = value!.trim();
|
||
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||
if (!ok) return 'Format email invalide';
|
||
return null;
|
||
}
|
||
|
||
String? _validatePassword(String? value) {
|
||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||
return null;
|
||
}
|
||
final base = _required(value, 'Mot de passe');
|
||
if (base != null) return base;
|
||
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||
return null;
|
||
}
|
||
|
||
String? _validatePhone(String? value) {
|
||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||
return null;
|
||
}
|
||
final base = _required(value, 'Téléphone');
|
||
if (base != null) return base;
|
||
final digits = _normalizePhone(value!);
|
||
if (digits.length != 10) {
|
||
return 'Le téléphone doit contenir 10 chiffres';
|
||
}
|
||
if (!digits.startsWith('0')) {
|
||
return 'Le téléphone doit commencer par 0';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String _normalizePhone(String raw) {
|
||
return raw.replaceAll(RegExp(r'\D'), '');
|
||
}
|
||
|
||
String _formatPhoneForDisplay(String raw) {
|
||
final normalized = _normalizePhone(raw);
|
||
final digits =
|
||
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < digits.length; i++) {
|
||
if (i > 0 && i.isEven) buffer.write(' ');
|
||
buffer.write(digits[i]);
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
String _toTitleCase(String raw) {
|
||
final trimmed = raw.trim();
|
||
if (trimmed.isEmpty) return trimmed;
|
||
final words = trimmed.split(RegExp(r'\s+'));
|
||
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
||
return normalizedWords.join(' ');
|
||
}
|
||
|
||
String _capitalizeComposedWord(String word) {
|
||
if (word.isEmpty) return word;
|
||
final lower = word.toLowerCase();
|
||
final separators = <String>{"-", "'", "’"};
|
||
final buffer = StringBuffer();
|
||
var capitalizeNext = true;
|
||
|
||
for (var i = 0; i < lower.length; i++) {
|
||
final char = lower[i];
|
||
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||
buffer.write(char.toUpperCase());
|
||
capitalizeNext = false;
|
||
} else {
|
||
buffer.write(char);
|
||
capitalizeNext = separators.contains(char);
|
||
}
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (_isSubmitting) return;
|
||
if (!_formKey.currentState!.validate()) return;
|
||
|
||
setState(() {
|
||
_isSubmitting = true;
|
||
});
|
||
|
||
try {
|
||
final normalizedNom = _toTitleCase(_nomController.text);
|
||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||
|
||
if (_isEditMode) {
|
||
if (widget.adminMode) {
|
||
await UserService.updateAdministrateur(
|
||
adminId: widget.initialUser!.id,
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
telephone: normalizedPhone.isEmpty
|
||
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||
: normalizedPhone,
|
||
password: passwordProvided ? _passwordController.text : null,
|
||
);
|
||
} else {
|
||
final currentUser = widget.initialUser!;
|
||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||
final initialEmail = currentUser.email.trim();
|
||
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||
|
||
final onlyRelaisChanged =
|
||
normalizedNom == initialNom &&
|
||
normalizedPrenom == initialPrenom &&
|
||
_emailController.text.trim() == initialEmail &&
|
||
normalizedPhone == initialPhone &&
|
||
!passwordProvided;
|
||
|
||
if (onlyRelaisChanged) {
|
||
await UserService.updateGestionnaireRelais(
|
||
gestionnaireId: currentUser.id,
|
||
relaisId: _selectedRelaisId,
|
||
);
|
||
} else {
|
||
await UserService.updateGestionnaire(
|
||
gestionnaireId: currentUser.id,
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
||
relaisId: _selectedRelaisId,
|
||
password: passwordProvided ? _passwordController.text : null,
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
if (widget.adminMode) {
|
||
await UserService.createAdministrateur(
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
password: _passwordController.text,
|
||
telephone: _normalizePhone(_telephoneController.text),
|
||
);
|
||
} else {
|
||
await UserService.createGestionnaire(
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
password: _passwordController.text,
|
||
telephone: _normalizePhone(_telephoneController.text),
|
||
relaisId: _selectedRelaisId,
|
||
);
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
_isEditMode
|
||
? (widget.adminMode
|
||
? 'Administrateur modifié avec succès.'
|
||
: 'Gestionnaire modifié avec succès.')
|
||
: (widget.adminMode
|
||
? 'Administrateur créé avec succès.'
|
||
: 'Gestionnaire créé avec succès.'),
|
||
),
|
||
),
|
||
);
|
||
Navigator.of(context).pop(true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
e.toString().replaceFirst('Exception: ', ''),
|
||
),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
} finally {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isSubmitting = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _delete() async {
|
||
if (!_isEditMode || _isSubmitting) return;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
return AlertDialog(
|
||
title: const Text('Confirmer la suppression'),
|
||
content: Text(
|
||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
child: const Text('Supprimer'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
|
||
if (confirmed != true) return;
|
||
|
||
setState(() {
|
||
_isSubmitting = true;
|
||
});
|
||
try {
|
||
await UserService.deleteUser(widget.initialUser!.id);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
||
);
|
||
Navigator.of(context).pop(true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
setState(() {
|
||
_isSubmitting = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_isEditMode
|
||
? (widget.adminMode
|
||
? 'Modifier un administrateur'
|
||
: 'Modifier un gestionnaire')
|
||
: (widget.adminMode
|
||
? 'Créer un administrateur'
|
||
: 'Créer un gestionnaire'),
|
||
),
|
||
),
|
||
if (_isEditMode)
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
tooltip: 'Fermer',
|
||
onPressed: _isSubmitting
|
||
? null
|
||
: () => Navigator.of(context).pop(false),
|
||
),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 620,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(child: _buildPrenomField()),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildNomField()),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildEmailField(),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(child: _buildPasswordField()),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildTelephoneField()),
|
||
],
|
||
),
|
||
if (widget.withRelais) ...[
|
||
const SizedBox(height: 12),
|
||
_buildRelaisField(),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
if (_isEditMode) ...[
|
||
OutlinedButton(
|
||
onPressed: _isSubmitting ? null : _delete,
|
||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||
child: const Text('Supprimer'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _isSubmitting ? null : _submit,
|
||
icon: _isSubmitting
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.edit),
|
||
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||
),
|
||
] else ...[
|
||
OutlinedButton(
|
||
onPressed:
|
||
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _isSubmitting ? null : _submit,
|
||
icon: _isSubmitting
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.person_add_alt_1),
|
||
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNomField() {
|
||
return TextFormField(
|
||
controller: _nomController,
|
||
textCapitalization: TextCapitalization.words,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Nom',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: (v) => _required(v, 'Nom'),
|
||
);
|
||
}
|
||
|
||
Widget _buildPrenomField() {
|
||
return TextFormField(
|
||
controller: _prenomController,
|
||
textCapitalization: TextCapitalization.words,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Prénom',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: (v) => _required(v, 'Prénom'),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmailField() {
|
||
return TextFormField(
|
||
controller: _emailController,
|
||
keyboardType: TextInputType.emailAddress,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Email',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: _validateEmail,
|
||
);
|
||
}
|
||
|
||
Widget _buildPasswordField() {
|
||
return TextFormField(
|
||
controller: _passwordController,
|
||
obscureText: _obscurePassword,
|
||
enableSuggestions: false,
|
||
autocorrect: false,
|
||
autofillHints: _isEditMode
|
||
? const <String>[]
|
||
: const [AutofillHints.newPassword],
|
||
decoration: InputDecoration(
|
||
labelText: _isEditMode
|
||
? 'Nouveau mot de passe'
|
||
: 'Mot de passe',
|
||
border: const OutlineInputBorder(),
|
||
suffixIcon: ExcludeFocus(
|
||
child: IconButton(
|
||
focusNode: _passwordToggleFocusNode,
|
||
onPressed: () {
|
||
setState(() {
|
||
_obscurePassword = !_obscurePassword;
|
||
});
|
||
},
|
||
icon: Icon(
|
||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
validator: _validatePassword,
|
||
);
|
||
}
|
||
|
||
Widget _buildTelephoneField() {
|
||
return TextFormField(
|
||
controller: _telephoneController,
|
||
keyboardType: TextInputType.phone,
|
||
inputFormatters: [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(10),
|
||
_FrenchPhoneNumberFormatter(),
|
||
],
|
||
decoration: const InputDecoration(
|
||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: _validatePhone,
|
||
);
|
||
}
|
||
|
||
Widget _buildRelaisField() {
|
||
final selectedValue = _selectedRelaisId != null &&
|
||
_relais.any((relais) => relais.id == _selectedRelaisId)
|
||
? _selectedRelaisId
|
||
: null;
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
DropdownButtonFormField<String?>(
|
||
isExpanded: true,
|
||
value: selectedValue,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Relais principal',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<String?>(
|
||
value: null,
|
||
child: Text('Aucun relais'),
|
||
),
|
||
..._relais.map(
|
||
(relais) => DropdownMenuItem<String?>(
|
||
value: relais.id,
|
||
child: Text(relais.nom),
|
||
),
|
||
),
|
||
],
|
||
onChanged: _isLoadingRelais
|
||
? null
|
||
: (value) {
|
||
setState(() {
|
||
_selectedRelaisId = value;
|
||
});
|
||
},
|
||
),
|
||
if (_isLoadingRelais) ...[
|
||
const SizedBox(height: 8),
|
||
const LinearProgressIndicator(minHeight: 2),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||
const _FrenchPhoneNumberFormatter();
|
||
|
||
@override
|
||
TextEditingValue formatEditUpdate(
|
||
TextEditingValue oldValue,
|
||
TextEditingValue newValue,
|
||
) {
|
||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < normalized.length; i++) {
|
||
if (i > 0 && i.isEven) buffer.write(' ');
|
||
buffer.write(normalized[i]);
|
||
}
|
||
final formatted = buffer.toString();
|
||
|
||
return TextEditingValue(
|
||
text: formatted,
|
||
selection: TextSelection.collapsed(offset: formatted.length),
|
||
);
|
||
}
|
||
} |