diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart new file mode 100644 index 0000000..ea8d072 --- /dev/null +++ b/frontend/lib/utils/nir_utils.dart @@ -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), + ); + } +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 0645bb1..7471c69 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; // Définition de l'enum pour les styles de couleur/fond @@ -30,6 +31,7 @@ class CustomAppTextField extends StatefulWidget { final Iterable? autofillHints; final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; + final List? inputFormatters; const CustomAppTextField({ super.key, @@ -114,6 +116,7 @@ class _CustomAppTextFieldState extends State { focusNode: widget.focusNode, obscureText: widget.obscureText, keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, autofillHints: widget.autofillHints, textInputAction: widget.textInputAction, onFieldSubmitted: widget.onFieldSubmitted, diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart new file mode 100644 index 0000000..e1beca1 --- /dev/null +++ b/frontend/lib/widgets/nir_text_field.dart @@ -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, + ); + } +} diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 24b8f56..1923b96 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -6,7 +7,9 @@ import 'dart:math' as math; import 'dart:io'; import '../models/card_assets.dart'; import '../config/display_config.dart'; +import '../utils/nir_utils.dart'; import 'custom_app_text_field.dart'; +import 'nir_text_field.dart'; import 'form_field_wrapper.dart'; import 'app_custom_checkbox.dart'; import 'hover_relief_widget.dart'; @@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State : ''; _birthCityController.text = data.birthCity; _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; _capacityController.text = data.capacity?.toString() ?? ''; _photoPathFramework = data.photoPath; @@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State dateOfBirth: _selectedDate, birthCity: _birthCityController.text, birthCountry: _birthCountryController.text, - nir: _nirController.text, + nir: normalizeNir(_nirController.text), agrementNumber: _agrementController.text, capacity: int.tryParse(_capacityController.text), ); @@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State children: [ Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), 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), @@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State ); } + /// 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" Widget _buildReadonlyField(String label, String value) { return Column( @@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State ], ), SizedBox(height: verticalSpacing), - _buildField( - config: config, - label: 'N° Sécurité Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR à 13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - 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; - }, + fieldWidth: double.infinity, + fieldHeight: config.isMobile ? 45.0 : 53.0, + labelFontSize: config.isMobile ? 15.0 : 22.0, + inputFontSize: config.isMobile ? 14.0 : 20.0, ), SizedBox(height: verticalSpacing), Row( @@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), - _buildField( - config: config, - label: 'N° Sécurité Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR à 13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - 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; - }, + fieldWidth: double.infinity, + fieldHeight: 45.0, + labelFontSize: 15.0, + inputFontSize: 14.0, ), const SizedBox(height: 12), @@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State VoidCallback? onTap, IconData? suffixIcon, String? Function(String?)? validator, + List? inputFormatters, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: onTap, suffixIcon: suffixIcon, validator: validator, + inputFormatters: inputFormatters, ); } }