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:
@@ -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 1–3, département 2A ou 2B pour la Corse.
|
/// 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;
|
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 {
|
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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user