diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index 01fcc4c..d1c34e1 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -10,8 +10,10 @@ import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.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/nir_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; @@ -361,7 +363,8 @@ class _AmDossierWizardState extends State { if (_adresseCtrl.text.trim().isEmpty) { return 'L’adresse est requise.'; } - if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.'; + final cpErr = validateFrenchPostalCode(_cpCtrl.text); + if (cpErr != null) return cpErr; if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; return null; } @@ -466,21 +469,24 @@ class _AmDossierWizardState extends State { return { 'email': normalizeEmailText(_emailCtrl.text), - 'prenom': _prenomCtrl.text.trim(), - 'nom': _nomCtrl.text.trim(), + 'prenom': formatPersonNameCase(_prenomCtrl.text), + 'nom': formatPersonNameCase(_nomCtrl.text), 'telephone': normalizePhone(_telCtrl.text), 'adresse': _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, 'code_postal': _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, - 'ville': - _villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null, + 'ville': _villeCtrl.text.trim().isNotEmpty + ? formatPersonNameCase(_villeCtrl.text) + : null, 'photo_base64': photoBase64, 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', 'consentement_photo': true, 'date_naissance': birthIso, - 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), - 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + 'lieu_naissance_ville': + formatPersonNameCase(_lieuNaissanceVilleCtrl.text), + 'lieu_naissance_pays': + formatPersonNameCase(_lieuNaissancePaysCtrl.text), 'nir': normalizeNir(_nirCtrl.text), 'numero_agrement': _agrementCtrl.text.trim(), 'date_agrement': agrementIso, @@ -695,12 +701,14 @@ class _AmDossierWizardState extends State { label: 'Ville de naissance', field: ValidationEditableField( controller: _lieuNaissanceVilleCtrl, + inputFormatters: const [PersonNameInputFormatter()], ), ), ValidationLabeledField( label: 'Pays de naissance', field: ValidationEditableField( controller: _lieuNaissancePaysCtrl, + inputFormatters: const [PersonNameInputFormatter()], ), ), ValidationLabeledField( diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 610dd98..a5d2df8 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -295,6 +297,158 @@ class ValidationEditableField extends StatelessWidget { } } +/// E-mail style validation — même supervision que login / création de compte : +/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation. +class ValidationEmailField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationEmailField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationEmailFieldState(); +} + +class _ValidationEmailFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final normalized = normalizeEmailText(c.text); + if (normalized != c.text) { + c.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + inputFormatters: const [EmailMaxLengthFormatter()], + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateEmail(value, allowEmpty: widget.allowEmpty), + ); + } +} + +/// Code postal FR — même supervision que création de compte : +/// chiffres uniquement (max 5) + validation à la perte de focus. +class ValidationPostalCodeField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPostalCodeField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => + _ValidationPostalCodeFieldState(); +} + +class _ValidationPostalCodeFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final trimmed = c.text.trim(); + if (trimmed != c.text) { + c.value = TextEditingValue( + text: trimmed, + selection: TextSelection.collapsed(offset: trimmed.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: kFrenchPostalCodeInputFormatters, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '5 chiffres', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty), + ); + } +} + /// Grille label/champ éditable (délègue à [ValidationFormGrid]). class ValidationEditableSection extends StatelessWidget { final List fields; diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index 91a1d30..e283e2f 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/user.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/widgets/admin/common/validation_detail_section.dart'; @@ -199,11 +200,17 @@ class IdentityBlock extends StatelessWidget { final String? title; fields: [ ValidationLabeledField( label: 'Nom', - field: ValidationEditableField(controller: _nomCtrl!), + field: ValidationEditableField( + controller: _nomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Prénom', - field: ValidationEditableField(controller: _prenomCtrl!), + field: ValidationEditableField( + controller: _prenomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Téléphone', @@ -215,10 +222,7 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Email', - field: ValidationEditableField( - controller: _emailCtrl!, - keyboardType: TextInputType.emailAddress, - ), + field: ValidationEmailField(controller: _emailCtrl!), ), ValidationLabeledField( label: 'Adresse (N° et Rue)', @@ -226,14 +230,14 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Code postal', - field: ValidationEditableField( - controller: _cpCtrl!, - keyboardType: TextInputType.number, - ), + field: ValidationPostalCodeField(controller: _cpCtrl!), ), ValidationLabeledField( label: 'Ville', - field: ValidationEditableField(controller: _villeCtrl!), + field: ValidationEditableField( + controller: _villeCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ], );