feat(frontend): NIR 15 car., formatage, validation, widget dédié (#102)

- nir_utils: normalizeNir, formatNir, validateNir (format + clé), Corse 2A/2B
- NirInputFormatter: formatage auto à la saisie (espaces + tiret)
- NirTextField: widget réutilisable pour champ NIR
- professional_info_form_screen: NIR 15 car., affichage formaté à l'init
- custom_app_text_field: paramètre inputFormatters

Refs: #102
Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-02-26 13:49:57 +01:00
parent a9c6b9e15b
commit 721f40599b
4 changed files with 196 additions and 25 deletions

View File

@ -0,0 +1,113 @@
import 'package:flutter/services.dart';
/// Utilitaires NIR (Numéro d'Inscription au Répertoire) INSEE, 15 caractères.
/// Corse : 2A (2A) et 2B (2B) au lieu de 19/20. Clé de contrôle : 97 - (NIR13 mod 97).
/// Normalise le NIR : 15 caractères, sans espaces ni séparateurs. Corse conservée (2A/2B).
String normalizeNir(String input) {
if (input.isEmpty) return '';
final cleaned = input.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '').toUpperCase();
final buf = StringBuffer();
int i = 0;
while (i < cleaned.length && buf.length < 15) {
final c = cleaned[i];
if (buf.length < 5) {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
} else if (buf.length == 5) {
if (c == '2' && i + 1 < cleaned.length && (cleaned[i + 1] == 'A' || cleaned[i + 1] == 'B')) {
buf.write('2');
buf.write(cleaned[i + 1]);
i += 2;
} else if ((c == 'A' || c == 'B')) {
buf.write('2');
buf.write(c);
i++;
} else if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) {
buf.write(c);
if (i + 1 < cleaned.length && cleaned[i + 1].compareTo('0') >= 0 && cleaned[i + 1].compareTo('9') <= 0) {
buf.write(cleaned[i + 1]);
i += 2;
} else {
i++;
}
} else {
i++;
}
} else {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
}
}
return buf.toString().length > 15 ? buf.toString().substring(0, 15) : buf.toString();
}
/// Retourne la chaîne brute à 15 caractères (chiffres + 2A ou 2B).
String nirToRaw(String normalized) {
String s = normalized.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '');
if (s.length > 15) s = s.substring(0, 15);
return s;
}
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34
String formatNir(String raw) {
final r = nirToRaw(raw);
if (r.length < 15) return r;
final dept = r.substring(5, 7);
final isCorsica = dept == '2A' || dept == '2B';
if (isCorsica) {
return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
}
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)}';
}
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
bool _isFormatValid(String raw) {
if (raw.length != 15) return false;
final dept = raw.substring(5, 7);
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{6}$').hasMatch(raw);
}
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A19, 2B18.
int _controlKey(String raw13) {
String n = raw13;
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
}
final big = int.tryParse(n);
if (big == null) return -1;
return 97 - (big % 97);
}
/// Valide le NIR (format + clé). Retourne null si valide, message d'erreur sinon.
String? validateNir(String? value) {
if (value == null || value.isEmpty) return 'NIR requis';
final raw = nirToRaw(value).toUpperCase();
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)';
final key = _controlKey(raw.substring(0, 13));
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
final expectedKey = raw.substring(13, 15);
if (key < 0 || keyStr != expectedKey) return 'Clé de contrôle NIR invalide';
return null;
}
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue;
final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
// Définition de l'enum pour les styles de couleur/fond // Définition de l'enum pour les styles de couleur/fond
@ -30,6 +31,7 @@ class CustomAppTextField extends StatefulWidget {
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters;
const CustomAppTextField({ const CustomAppTextField({
super.key, super.key,
@ -114,6 +116,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
focusNode: widget.focusNode, focusNode: widget.focusNode,
obscureText: widget.obscureText, obscureText: widget.obscureText,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints, autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted, onFieldSubmitted: widget.onFieldSubmitted,

View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../utils/nir_utils.dart';
import 'custom_app_text_field.dart';
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire 15 caractères).
/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse.
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget {
final TextEditingController controller;
final String labelText;
final String hintText;
final String? Function(String?)? validator;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final CustomAppTextFieldStyle style;
const NirTextField({
super.key,
required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
this.enabled = true,
this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige,
});
@override
Widget build(BuildContext context) {
return CustomAppTextField(
controller: controller,
labelText: labelText,
hintText: hintText,
fieldWidth: fieldWidth,
fieldHeight: fieldHeight,
labelFontSize: labelFontSize,
inputFontSize: inputFontSize,
keyboardType: TextInputType.text,
validator: validator ?? validateNir,
inputFormatters: [NirInputFormatter()],
enabled: enabled,
readOnly: readOnly,
style: style,
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -6,7 +7,9 @@ import 'dart:math' as math;
import 'dart:io'; import 'dart:io';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import '../utils/nir_utils.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'nir_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart'; import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart'; import 'hover_relief_widget.dart';
@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
: ''; : '';
_birthCityController.text = data.birthCity; _birthCityController.text = data.birthCity;
_birthCountryController.text = data.birthCountry; _birthCountryController.text = data.birthCountry;
_nirController.text = data.nir; final nirRaw = nirToRaw(data.nir);
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
_agrementController.text = data.agrementNumber; _agrementController.text = data.agrementNumber;
_capacityController.text = data.capacity?.toString() ?? ''; _capacityController.text = data.capacity?.toString() ?? '';
_photoPathFramework = data.photoPath; _photoPathFramework = data.photoPath;
@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
dateOfBirth: _selectedDate, dateOfBirth: _selectedDate,
birthCity: _birthCityController.text, birthCity: _birthCityController.text,
birthCountry: _birthCountryController.text, birthCountry: _birthCountryController.text,
nir: _nirController.text, nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text, agrementNumber: _agrementController.text,
capacity: int.tryParse(_capacityController.text), capacity: int.tryParse(_capacityController.text),
); );
@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [ children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)), Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
); );
} }
/// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse).
String _formatNirForDisplay(String value) {
final raw = nirToRaw(value);
return raw.length == 15 ? formatNir(raw) : value;
}
/// Helper pour champ Readonly style "Beige" /// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) { Widget _buildReadonlyField(String label, String value) {
return Column( return Column(
@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
], ],
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
_buildField( NirTextField(
config: config,
label: 'N° Sécurité Sociale (NIR)',
controller: _nirController, controller: _nirController,
hint: 'Votre NIR à 13 chiffres', fieldWidth: double.infinity,
keyboardType: TextInputType.number, fieldHeight: config.isMobile ? 45.0 : 53.0,
validator: (v) { labelFontSize: config.isMobile ? 15.0 : 22.0,
if (v == null || v.isEmpty) return 'NIR requis'; inputFontSize: config.isMobile ? 14.0 : 20.0,
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
return null;
},
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
Row( Row(
@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildField( NirTextField(
config: config,
label: 'N° Sécurité Sociale (NIR)',
controller: _nirController, controller: _nirController,
hint: 'Votre NIR à 13 chiffres', fieldWidth: double.infinity,
keyboardType: TextInputType.number, fieldHeight: 45.0,
validator: (v) { labelFontSize: 15.0,
if (v == null || v.isEmpty) return 'NIR requis'; inputFontSize: 14.0,
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
return null;
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
VoidCallback? onTap, VoidCallback? onTap,
IconData? suffixIcon, IconData? suffixIcon,
String? Function(String?)? validator, String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
onTap: onTap, onTap: onTap,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
validator: validator, validator: validator,
inputFormatters: inputFormatters,
); );
} }
} }