Squash merge de develop vers master. Livrables principaux (ticket #101 et mise au point associée) : - Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent - Payload DTO (parents, enfants, photos base64, CGU) et services Auth - Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées - Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/ Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.). Closes #101 Made-with: Cursor
146 lines
4.8 KiB
Dart
146 lines
4.8 KiB
Dart
import 'package:flutter/services.dart';
|
||
|
||
/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78).
|
||
|
||
/// Ne garde que les chiffres (max 10).
|
||
String normalizePhone(String raw) {
|
||
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||
return digits.length > 10 ? digits.substring(0, 10) : digits;
|
||
}
|
||
|
||
/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`.
|
||
/// Chaîne vide : `true` (pas encore de saisie).
|
||
bool frenchNationalPhoneStartsWithZero(String digitsOnly) {
|
||
if (digitsOnly.isEmpty) {
|
||
return true;
|
||
}
|
||
return digitsOnly.startsWith('0');
|
||
}
|
||
|
||
/// Validation téléphone France : 10 chiffres, commence par `0`, 2ᵉ chiffre 1–9 (format national).
|
||
///
|
||
/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.).
|
||
/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée.
|
||
String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) {
|
||
final trimmed = raw?.trim() ?? '';
|
||
if (trimmed.isEmpty) {
|
||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
||
}
|
||
final digits = normalizePhone(trimmed);
|
||
if (digits.isEmpty) {
|
||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
||
}
|
||
if (!frenchNationalPhoneStartsWithZero(digits)) {
|
||
return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).';
|
||
}
|
||
if (digits.length < 10) {
|
||
return 'Le numéro doit contenir 10 chiffres.';
|
||
}
|
||
if (digits.length > 10) {
|
||
return 'Le numéro ne peut pas dépasser 10 chiffres.';
|
||
}
|
||
if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) {
|
||
return 'Numéro de téléphone invalide.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
|
||
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.).
|
||
String formatPhoneForDisplay(String raw) {
|
||
if (raw.trim().isEmpty) {
|
||
return raw;
|
||
}
|
||
final normalized = normalizePhone(raw);
|
||
if (normalized.isEmpty) {
|
||
return raw;
|
||
}
|
||
return formatFrenchPhoneDigits(normalized);
|
||
}
|
||
|
||
/// Affiche les chiffres par paires (ex. `0612345678` → `06 12 34 56 78`).
|
||
String formatFrenchPhoneDigits(String normalizedDigits) {
|
||
if (normalizedDigits.isEmpty) {
|
||
return '';
|
||
}
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < normalizedDigits.length; i++) {
|
||
if (i > 0 && i.isEven) {
|
||
buffer.write(' ');
|
||
}
|
||
buffer.write(normalizedDigits[i]);
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) {
|
||
final k = digitCountBeforeCursor.clamp(0, normalized.length);
|
||
if (k == 0) {
|
||
return 0;
|
||
}
|
||
return formatFrenchPhoneDigits(normalized.substring(0, k)).length;
|
||
}
|
||
|
||
/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres.
|
||
///
|
||
/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` → `06`).
|
||
/// - Si l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
|
||
/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté.
|
||
class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||
const FrenchPhoneNumberFormatter();
|
||
|
||
static const String _autoPrefixFirstDigits = '1234567';
|
||
|
||
@override
|
||
TextEditingValue formatEditUpdate(
|
||
TextEditingValue oldValue,
|
||
TextEditingValue newValue,
|
||
) {
|
||
var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||
var didPrependZero = false;
|
||
|
||
if (digits.isNotEmpty) {
|
||
final first = digits[0];
|
||
if (first == '8' || first == '9') {
|
||
return oldValue;
|
||
}
|
||
if (first != '0' && _autoPrefixFirstDigits.contains(first)) {
|
||
digits = '0$digits';
|
||
didPrependZero = true;
|
||
if (digits.length > 10) {
|
||
digits = digits.substring(0, 10);
|
||
}
|
||
} else if (first != '0') {
|
||
return oldValue;
|
||
}
|
||
}
|
||
|
||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||
final formatted = formatFrenchPhoneDigits(normalized);
|
||
|
||
final sel = newValue.selection;
|
||
var digitsBeforeCursor = newValue.text
|
||
.substring(0, sel.start.clamp(0, newValue.text.length))
|
||
.replaceAll(RegExp(r'\D'), '')
|
||
.length;
|
||
if (didPrependZero) {
|
||
digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length);
|
||
}
|
||
|
||
final clampedOffset =
|
||
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
|
||
|
||
return TextEditingValue(
|
||
text: formatted,
|
||
selection: TextSelection.collapsed(offset: clampedOffset),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.).
|
||
final List<TextInputFormatter> frenchPhoneInputFormatters = [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(10),
|
||
const FrenchPhoneNumberFormatter(),
|
||
];
|