Branche le flux création/édition des administrateurs sur la modale partagée et renforce le champ téléphone avec formatage/validation pour garantir un payload API cohérent. Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
16 KiB
Dart
542 lines
16 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();
|
|
|
|
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 = 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();
|
|
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) {
|
|
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'), '');
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_isSubmitting) return;
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() {
|
|
_isSubmitting = true;
|
|
});
|
|
|
|
try {
|
|
if (_isEditMode) {
|
|
if (widget.adminMode) {
|
|
await UserService.updateAdministrateur(
|
|
adminId: widget.initialUser!.id,
|
|
nom: _nomController.text.trim(),
|
|
prenom: _prenomController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
telephone: _normalizePhone(_telephoneController.text),
|
|
password: _passwordController.text.trim().isEmpty
|
|
? null
|
|
: _passwordController.text,
|
|
);
|
|
} else {
|
|
await UserService.updateGestionnaire(
|
|
gestionnaireId: widget.initialUser!.id,
|
|
nom: _nomController.text.trim(),
|
|
prenom: _prenomController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
telephone: _normalizePhone(_telephoneController.text),
|
|
relaisId: _selectedRelaisId,
|
|
password: _passwordController.text.trim().isEmpty
|
|
? null
|
|
: _passwordController.text,
|
|
);
|
|
}
|
|
} else {
|
|
if (widget.adminMode) {
|
|
await UserService.createAdministrateur(
|
|
nom: _nomController.text.trim(),
|
|
prenom: _prenomController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
telephone: _normalizePhone(_telephoneController.text),
|
|
);
|
|
} else {
|
|
await UserService.createGestionnaire(
|
|
nom: _nomController.text.trim(),
|
|
prenom: _prenomController.text.trim(),
|
|
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: Focus(
|
|
skipTraversal: true,
|
|
canRequestFocus: false,
|
|
child: IconButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_obscurePassword = !_obscurePassword;
|
|
});
|
|
},
|
|
icon: Icon(
|
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
validator: _validatePassword,
|
|
);
|
|
}
|
|
|
|
Widget _buildTelephoneField() {
|
|
return TextFormField(
|
|
controller: _telephoneController,
|
|
keyboardType: TextInputType.phone,
|
|
inputFormatters: const [
|
|
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),
|
|
);
|
|
}
|
|
} |