fix(#156): valider téléphone au blur et NIR en live.

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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 16:28:35 +02:00
co-authored by Cursor
parent 4ae334b247
commit e3552667bc
5 changed files with 203 additions and 20 deletions
+37 -9
View File
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
return s; 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) { String formatNir(String raw) {
final r = nirToRaw(raw); final r = nirToRaw(raw).toUpperCase();
if (r.length < 15) return r; if (r.isEmpty) return '';
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. final buf = StringBuffer();
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)}'; 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 13, département 2A ou 2B pour la Corse. /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
return null; 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 derreur 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 { class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final raw = normalizeNir(newValue.text); 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 formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
selection: TextSelection.collapsed(offset: offset), selection: TextSelection.collapsed(offset: formatted.length),
); );
} }
} }
@@ -682,11 +682,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'NIR', label: 'NIR',
field: ValidationEditableField( field: ValidationNirField(controller: _nirCtrl),
controller: _nirCtrl,
hintText: '15 caractères',
inputFormatters: [NirInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Date de naissance', label: 'Date de naissance',
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/email_utils.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 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart'; import 'admin_detail_modal.dart';
@@ -449,6 +451,167 @@ class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
} }
} }
/// 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<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
}
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
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<ValidationNirField> createState() => _ValidationNirFieldState();
}
class _ValidationNirFieldState extends State<ValidationNirField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
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]). /// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget { class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields; final List<ValidationLabeledField> fields;
@@ -214,11 +214,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Téléphone', label: 'Téléphone',
field: ValidationEditableField( field: ValidationPhoneField(controller: _telCtrl!),
controller: _telCtrl!,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Email', label: 'Email',
+1 -1
View File
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
inputFontSize: inputFontSize, inputFontSize: inputFontSize,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
validator: validator ?? validateNir, validator: validator ?? validateNir,
inputFormatters: [NirInputFormatter()], inputFormatters: const [NirInputFormatter()],
enabled: enabled, enabled: enabled,
readOnly: readOnly, readOnly: readOnly,
style: style, style: style,