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:
parent
a9c6b9e15b
commit
721f40599b
113
frontend/lib/utils/nir_utils.dart
Normal file
113
frontend/lib/utils/nir_utils.dart
Normal 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 2A→19, 2B→18.
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
55
frontend/lib/widgets/nir_text_field.dart
Normal file
55
frontend/lib/widgets/nir_text_field.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user