Files
petitspas/frontend/lib/widgets/dashboard/staff_user_form_modal.dart
T
jmartinandCursor 1d6261b312 feat(#164): uniformise la modale staff (gestionnaire / admin).
Shell 930 + Validation*Field, dirty-state, StaffUserFormModal sous
widgets/dashboard/ ; retire AdminUserFormDialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:29:13 +02:00

795 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Modale création / édition / consultation staff (gestionnaire / admin) — #164.
class StaffUserFormModal extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const StaffUserFormModal({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<StaffUserFormModal> createState() => _StaffUserFormModalState();
}
class _StaffUserFormModalState extends State<StaffUserFormModal> {
static const double _modalWidth = 930;
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;
bool _dirty = false;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
String? _currentUserRole;
String _baselineNom = '';
String _baselinePrenom = '';
String _baselineEmail = '';
String _baselinePhone = '';
String? _baselineRelaisId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
bool get _fieldsEnabled => !widget.readOnly && !_isSubmitting;
String get _targetRoleKey {
if (widget.initialUser != null) {
return (widget.initialUser!.role).toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
default:
return Icons.person_outline;
}
}
@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 ?? '');
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
_captureBaseline();
}
for (final c in [
_nomController,
_prenomController,
_emailController,
_passwordController,
_telephoneController,
]) {
c.addListener(_onFieldChanged);
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
_loadCurrentUserId();
}
void _captureBaseline() {
_baselineNom = formatPersonNameCase(_nomController.text);
_baselinePrenom = formatPersonNameCase(_prenomController.text);
_baselineEmail = normalizeEmailText(_emailController.text);
_baselinePhone = normalizePhone(_telephoneController.text);
_baselineRelaisId = _selectedRelaisId;
}
void _onFieldChanged() {
if (widget.readOnly || !_isEditMode) return;
final dirty = _computeDirty();
if (dirty != _dirty) setState(() => _dirty = dirty);
}
bool _computeDirty() {
if (!_isEditMode) return true;
final nom = formatPersonNameCase(_nomController.text);
final prenom = formatPersonNameCase(_prenomController.text);
final email = normalizeEmailText(_emailController.text);
final phone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
return nom != _baselineNom ||
prenom != _baselinePrenom ||
email != _baselineEmail ||
phone != _baselinePhone ||
passwordProvided ||
_selectedRelaisId != _baselineRelaisId;
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
_currentUserRole = cached.role;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
});
}
@override
void dispose() {
for (final c in [
_nomController,
_prenomController,
_emailController,
_passwordController,
_telephoneController,
]) {
c.removeListener(_onFieldChanged);
c.dispose();
}
_passwordToggleFocusNode.dispose();
super.dispose();
}
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
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 {
filtered.addAll(_fallbackRelaisFromUser());
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
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;
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (_isEditMode && !_dirty) return;
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
final normalizedNom = formatPersonNameCase(_nomController.text);
final normalizedPrenom = formatPersonNameCase(_prenomController.text);
final normalizedPhone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = formatPersonNameCase(widget.initialUser!.nom ?? '');
final lockedPrenom =
formatPersonNameCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: normalizeEmailText(_emailController.text),
telephone: normalizedPhone.isEmpty
? normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = formatPersonNameCase(currentUser.nom ?? '');
final initialPrenom = formatPersonNameCase(currentUser.prenom ?? '');
final initialEmail = normalizeEmailText(currentUser.email);
final initialPhone = normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged = normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
normalizeEmailText(_emailController.text) == 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: normalizeEmailText(_emailController.text),
telephone:
normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: normalizeEmailText(_emailController.text),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: normalizeEmailText(_emailController.text),
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) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: widget.adminMode
? 'Supprimer l\'administrateur'
: 'Supprimer le gestionnaire',
people: [
widget.adminMode
? SuppressionPersonLine.administrateur(name)
: SuppressionPersonLine.gestionnaire(name),
],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed) return;
setState(() => _isSubmitting = true);
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.adminMode
? 'Administrateur supprimé.'
: '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);
}
}
String _headerTitle() {
if (!_isEditMode) {
return widget.adminMode
? 'Créer un administrateur'
: 'Créer un gestionnaire';
}
final prenom = (_prenomController.text.trim().isNotEmpty
? _prenomController.text
: (widget.initialUser?.prenom ?? ''))
.trim();
final nom = (_nomController.text.trim().isNotEmpty
? _nomController.text
: (widget.initialUser?.nom ?? ''))
.trim();
final full = '$prenom $nom'.trim();
if (full.isNotEmpty) return full;
return widget.initialUser?.email ?? _targetRoleLabel;
}
Widget _buildFooter() {
if (widget.readOnly) {
return Row(
children: [
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
],
);
}
if (!_isEditMode) {
return Row(
children: [
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
const Spacer(),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Créer'),
),
],
);
}
return Row(
children: [
if (_canDeleteTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
),
child: const Text('Supprimer'),
),
if (_canDeleteTarget) const SizedBox(width: 8),
TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
const Spacer(),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
Widget _namedField({
required String label,
required TextEditingController controller,
required String requiredLabel,
bool enabled = true,
}) {
return ValidationLabeledField(
label: label,
field: SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: TextFormField(
controller: controller,
enabled: enabled,
textCapitalization: TextCapitalization.words,
textAlignVertical: TextAlignVertical.center,
inputFormatters: const [PersonNameInputFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(),
validator: (!enabled || widget.readOnly)
? null
: (v) => _required(v, requiredLabel),
),
),
);
}
Widget _passwordField() {
return ValidationLabeledField(
label: _isEditMode ? 'Nouveau mot de passe' : 'Mot de passe',
field: SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: TextFormField(
controller: _passwordController,
enabled: _fieldsEnabled,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input().copyWith(
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: !_fieldsEnabled
? null
: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
),
),
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: widget.readOnly ? null : _validatePassword,
),
),
);
}
Widget _relaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return ValidationLabeledField(
label: 'Relais principal',
field: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: ValidationFieldDecoration.input(),
style: ValidationFormMetrics.fieldTextStyle,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || !_fieldsEnabled)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
if (_isEditMode) {
_dirty = _computeDirty();
}
});
},
),
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
),
);
}
@override
Widget build(BuildContext context) {
final nameEnabled =
_fieldsEnabled && !_isLockedAdminIdentity;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, right: 10),
child: Icon(
_targetRoleIcon,
size: 22,
color: ValidationModalTheme.primaryActionBackground,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_isEditMode) ...[
const SizedBox(height: 4),
Text(
_targetRoleLabel,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
tooltip: 'Fermer',
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _namedField(
label: 'Prénom',
controller: _prenomController,
requiredLabel: 'Prénom',
enabled: nameEnabled,
),
),
const SizedBox(width: 12),
Expanded(
child: _namedField(
label: 'Nom',
controller: _nomController,
requiredLabel: 'Nom',
enabled: nameEnabled,
),
),
],
),
SizedBox(height: ValidationFormMetrics.rowGapBelow),
ValidationLabeledField(
label: 'Email',
field: IgnorePointer(
ignoring: !_fieldsEnabled,
child: ValidationEmailField(
controller: _emailController,
hintText: 'ex. nom@domaine.fr',
),
),
),
SizedBox(height: ValidationFormMetrics.rowGapBelow),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _passwordField()),
const SizedBox(width: 12),
Expanded(
child: ValidationLabeledField(
label: 'Téléphone',
field: IgnorePointer(
ignoring: !_fieldsEnabled,
child: ValidationPhoneField(
controller: _telephoneController,
hintText: '06 12 34 56 78',
allowEmpty: _isEditMode,
),
),
),
),
],
),
if (widget.withRelais) ...[
SizedBox(height: ValidationFormMetrics.rowGapBelow),
_relaisField(),
],
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 16, 14),
child: _buildFooter(),
),
],
),
),
),
);
}
}