From e3552667bc19b5ba821c319cdcfb7b514c568598 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:28:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(#156):=20valider=20t=C3=A9l=C3=A9phone=20au?= =?UTF-8?q?=20blur=20et=20NIR=20en=20live.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatage NIR progressif + contrôles au fil de la saisie ; téléphone validé à la perte de focus comme e-mail / CP. Co-authored-by: Cursor --- frontend/lib/utils/nir_utils.dart | 46 ++++- .../lib/widgets/admin/am_dossier_wizard.dart | 6 +- .../common/validation_detail_section.dart | 163 ++++++++++++++++++ .../lib/widgets/common/identity_block.dart | 6 +- frontend/lib/widgets/nir_text_field.dart | 2 +- 5 files changed, 203 insertions(+), 20 deletions(-) diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index ac64e05..01beb8e 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -49,12 +49,21 @@ String nirToRaw(String normalized) { return s; } -/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse). +/// Formate pour affichage (complet ou en cours de saisie) : +/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse). String formatNir(String raw) { - final r = nirToRaw(raw); - if (r.length < 15) return r; - // Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. - return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; + final r = nirToRaw(raw).toUpperCase(); + if (r.isEmpty) return ''; + final buf = StringBuffer(); + for (var i = 0; i < r.length && i < 15; i++) { + if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) { + buf.write(' '); + } else if (i == 13) { + buf.write(' - '); + } + buf.write(r[i]); + } + return buf.toString(); } /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse. @@ -92,20 +101,39 @@ String? validateNir(String? value) { return null; } -/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles. +/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ; +/// dès 15 caractères, même contrôles que [validateNir]. +String? validateNirTyping(String? value) { + if (value == null || value.trim().isEmpty) return null; + final raw = nirToRaw(value).toUpperCase(); + if (raw.isEmpty) return null; + if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') { + return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)'; + } + if (raw.length < 15) return null; + return validateNir(value); +} + +/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles. class NirInputFormatter extends TextInputFormatter { + const NirInputFormatter(); + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final raw = normalizeNir(newValue.text); - if (raw.isEmpty) return newValue; + if (raw.isEmpty) { + return const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + } final formatted = formatNir(raw); - final offset = formatted.length; return TextEditingValue( text: formatted, - selection: TextSelection.collapsed(offset: offset), + selection: TextSelection.collapsed(offset: formatted.length), ); } } diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index d1c34e1..dca588f 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -682,11 +682,7 @@ class _AmDossierWizardState extends State { fields: [ ValidationLabeledField( label: 'NIR', - field: ValidationEditableField( - controller: _nirCtrl, - hintText: '15 caractères', - inputFormatters: [NirInputFormatter()], - ), + field: ValidationNirField(controller: _nirCtrl), ), ValidationLabeledField( label: 'Date de naissance', diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a5d2df8..b133748 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,6 +1,8 @@ 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/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; @@ -449,6 +451,167 @@ class _ValidationPostalCodeFieldState extends State { } } +/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus. +class ValidationPhoneField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPhoneField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationPhoneFieldState(); +} + +class _ValidationPhoneFieldState 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 digits = normalizePhone(c.text); + final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.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.phone, + textInputAction: TextInputAction.next, + inputFormatters: frenchPhoneInputFormatters, + 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) => + validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty), + ); + } +} + +/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur. +class ValidationNirField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationNirField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationNirFieldState(); +} + +class _ValidationNirFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + bool _blurred = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + setState(() => _blurred = true); + final c = widget.controller; + final raw = nirToRaw(c.text).toUpperCase(); + final formatted = raw.isEmpty ? '' : formatNir(raw); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + String? _validator(String? value) { + if (_blurred) { + if (widget.allowEmpty && (value == null || value.trim().isEmpty)) { + return null; + } + return validateNir(value); + } + return validateNirTyping(value); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + autovalidateMode: AutovalidateMode.onUserInteraction, + inputFormatters: const [NirInputFormatter()], + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '1 12 34 56 789 012 - 34', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + onChanged: (_) => _fieldKey.currentState?.validate(), + validator: _validator, + ); + } +} + /// 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 e283e2f..d42de66 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -214,11 +214,7 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Téléphone', - field: ValidationEditableField( - controller: _telCtrl!, - keyboardType: TextInputType.phone, - inputFormatters: frenchPhoneInputFormatters, - ), + field: ValidationPhoneField(controller: _telCtrl!), ), ValidationLabeledField( label: 'Email', diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 7ea3d94..16fcff9 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget { inputFontSize: inputFontSize, keyboardType: TextInputType.text, validator: validator ?? validateNir, - inputFormatters: [NirInputFormatter()], + inputFormatters: const [NirInputFormatter()], enabled: enabled, readOnly: readOnly, style: style,