[#101] [Frontend] Inscription parent — API, soumission et validation
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
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
class DataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
// Méthodes publiques pour la génération de nombres aléatoires
|
||||
static int randomInt(int max) => _random.nextInt(max);
|
||||
static int randomIntInRange(int min, int max) => min + _random.nextInt(max - min);
|
||||
static bool randomBool() => _random.nextBool();
|
||||
|
||||
static final List<String> _firstNames = [
|
||||
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Félix', 'Gabrielle', 'Hugo', 'Inès', 'Jules',
|
||||
'Léa', 'Manon', 'Nathan', 'Oscar', 'Pauline', 'Quentin', 'Raphaël', 'Sophie', 'Théo', 'Victoire'
|
||||
];
|
||||
|
||||
static final List<String> _lastNames = [
|
||||
'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand', 'Leroy', 'Moreau',
|
||||
'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David', 'Bertrand', 'Roux', 'Vincent', 'Fournier'
|
||||
];
|
||||
|
||||
static final List<String> _addressSuffixes = [
|
||||
'Rue de la Paix', 'Boulevard des Rêves', 'Avenue du Soleil', 'Place des Étoiles', 'Chemin des Champs'
|
||||
];
|
||||
|
||||
static final List<String> _motivationSnippets = [
|
||||
'Nous cherchons une personne de confiance.',
|
||||
'Nos horaires sont atypiques.',
|
||||
'Notre enfant est plein de vie.',
|
||||
'Nous souhaitons une garde à temps plein.',
|
||||
'Une adaptation en douceur est primordiale pour nous.',
|
||||
'Nous avons hâte de vous rencontrer.',
|
||||
'La pédagogie Montessori nous intéresse.'
|
||||
];
|
||||
|
||||
static String firstName() => _firstNames[_random.nextInt(_firstNames.length)];
|
||||
static String lastName() => _lastNames[_random.nextInt(_lastNames.length)];
|
||||
static String address() => "${_random.nextInt(100) + 1} ${_addressSuffixes[_random.nextInt(_addressSuffixes.length)]}";
|
||||
static String postalCode() => "750${_random.nextInt(10)}${_random.nextInt(10)}";
|
||||
static String city() => "Paris";
|
||||
static String phone() => "06${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}";
|
||||
static String email(String firstName, String lastName) => "${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com";
|
||||
static String password() => "password123"; // Simple pour le test
|
||||
|
||||
static String dob({bool isUnborn = false}) {
|
||||
final now = DateTime.now();
|
||||
if (isUnborn) {
|
||||
final provisionalDate = now.add(Duration(days: _random.nextInt(180) + 30)); // Entre 1 et 7 mois dans le futur
|
||||
return "${provisionalDate.day.toString().padLeft(2, '0')}/${provisionalDate.month.toString().padLeft(2, '0')}/${provisionalDate.year}";
|
||||
} else {
|
||||
final birthYear = now.year - _random.nextInt(3); // Enfants de 0 à 2 ans
|
||||
final birthMonth = _random.nextInt(12) + 1;
|
||||
final birthDay = _random.nextInt(28) + 1; // Simple, évite les pbs de jours/mois
|
||||
return "${birthDay.toString().padLeft(2, '0')}/${birthMonth.toString().padLeft(2, '0')}/${birthYear}";
|
||||
}
|
||||
}
|
||||
|
||||
static bool boolean() => _random.nextBool();
|
||||
|
||||
static String motivation() {
|
||||
int count = _random.nextInt(3) + 2; // 2 à 4 phrases
|
||||
List<String> chosenSnippets = [];
|
||||
while(chosenSnippets.length < count) {
|
||||
String snippet = _motivationSnippets[_random.nextInt(_motivationSnippets.length)];
|
||||
if (!chosenSnippets.contains(snippet)) {
|
||||
chosenSnippets.add(snippet);
|
||||
}
|
||||
}
|
||||
return chosenSnippets.join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Longueur maximale d’une adresse e-mail (RFC 5321).
|
||||
const int kEmailMaxLength = 254;
|
||||
|
||||
/// Trim + minuscules (usage à la perte de focus et avant validation côté API).
|
||||
String normalizeEmailText(String raw) {
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Motif volontairement simple pour l’UI (pas une validation RFC complète).
|
||||
/// Local + @ + domaine avec au moins un point ; pas d’espaces.
|
||||
final RegExp kAppEmailPattern = RegExp(
|
||||
r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$',
|
||||
);
|
||||
|
||||
/// Indique si [trimmed] est un e-mail plausible (insensible à la casse).
|
||||
bool isValidEmailFormat(String trimmed) {
|
||||
final s = normalizeEmailText(trimmed);
|
||||
if (s.isEmpty || s.length > kEmailMaxLength) {
|
||||
return false;
|
||||
}
|
||||
return kAppEmailPattern.hasMatch(s);
|
||||
}
|
||||
|
||||
/// Validation centralisée pour les champs e-mail.
|
||||
///
|
||||
/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée.
|
||||
String? validateEmail(String? raw, {bool allowEmpty = false}) {
|
||||
final s = normalizeEmailText(raw ?? '');
|
||||
if (s.isEmpty) {
|
||||
return allowEmpty ? null : 'L’adresse e-mail est obligatoire.';
|
||||
}
|
||||
if (s.length > kEmailMaxLength) {
|
||||
return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).';
|
||||
}
|
||||
if (!kAppEmailPattern.hasMatch(s)) {
|
||||
return 'Le format de l’adresse e-mail est incorrect.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]).
|
||||
class EmailMaxLengthFormatter extends TextInputFormatter {
|
||||
const EmailMaxLengthFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (newValue.text.length <= kEmailMaxLength) {
|
||||
return newValue;
|
||||
}
|
||||
return oldValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||
|
||||
String formatPersonNameCase(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
final words = trimmed.split(RegExp(r'\s+'));
|
||||
return words.map(_capitalizeComposedWord).join(' ');
|
||||
}
|
||||
|
||||
String _capitalizeComposedWord(String word) {
|
||||
if (word.isEmpty) {
|
||||
return word;
|
||||
}
|
||||
final lower = word.toLowerCase();
|
||||
const separators = <String>{'-', "'", '’'};
|
||||
final buffer = StringBuffer();
|
||||
var capitalizeNext = true;
|
||||
|
||||
for (var i = 0; i < lower.length; i++) {
|
||||
final char = lower[i];
|
||||
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||||
buffer.write(char.toUpperCase());
|
||||
capitalizeNext = false;
|
||||
} else {
|
||||
buffer.write(char);
|
||||
capitalizeNext = separators.contains(char);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/user_registration_data.dart';
|
||||
import 'email_utils.dart';
|
||||
|
||||
/// Construction du body `POST /auth/register/parent` à partir du state d'inscription.
|
||||
/// Aligné sur [RegisterParentCompletDto] (backend).
|
||||
class ParentRegistrationPayload {
|
||||
ParentRegistrationPayload._();
|
||||
|
||||
static final RegExp _phoneFr = RegExp(r'^(\+33|0)[1-9](\d{2}){4}$');
|
||||
/// Aligné sur `GenreType` (backend) : JSON exact `H`, `F`, `Autre`.
|
||||
static const Set<String> apiGenres = {'H', 'F', 'Autre'};
|
||||
|
||||
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
|
||||
static String? validateForApi(UserRegistrationData d) {
|
||||
final p1 = d.parent1;
|
||||
final p1Email = normalizeEmailText(p1.email);
|
||||
if (p1Email.isEmpty) {
|
||||
return 'L’email du parent principal est requis.';
|
||||
}
|
||||
if (!isValidEmailFormat(p1Email)) {
|
||||
return 'L’email du parent principal n’est pas au bon format.';
|
||||
}
|
||||
if (p1.firstName.trim().length < 2) {
|
||||
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
|
||||
}
|
||||
if (p1.lastName.trim().length < 2) {
|
||||
return 'Le nom du parent principal doit contenir au moins 2 caractères.';
|
||||
}
|
||||
final tel = _normalizePhone(p1.phone);
|
||||
if (!_phoneFr.hasMatch(tel)) {
|
||||
return 'Le numéro de téléphone du parent principal n’est pas valide (ex. 0612345678).';
|
||||
}
|
||||
if (!d.cguAccepted) {
|
||||
return 'Vous devez accepter les CGU et la politique de confidentialité.';
|
||||
}
|
||||
if (d.children.isEmpty) {
|
||||
return 'Au moins un enfant est requis.';
|
||||
}
|
||||
|
||||
final p2 = d.parent2;
|
||||
if (p2 != null) {
|
||||
final any = p2.email.trim().isNotEmpty ||
|
||||
p2.firstName.trim().isNotEmpty ||
|
||||
p2.lastName.trim().isNotEmpty ||
|
||||
p2.phone.trim().isNotEmpty;
|
||||
if (any) {
|
||||
final p2Email = normalizeEmailText(p2.email);
|
||||
if (p2Email.isEmpty ||
|
||||
p2.firstName.trim().length < 2 ||
|
||||
p2.lastName.trim().length < 2) {
|
||||
return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).';
|
||||
}
|
||||
if (!isValidEmailFormat(p2Email)) {
|
||||
return 'L’email du co-parent n’est pas au bon format.';
|
||||
}
|
||||
if (p2Email == p1Email) {
|
||||
return 'L’email du co-parent doit être différent de celui du parent principal.';
|
||||
}
|
||||
final tel2 = _normalizePhone(p2.phone);
|
||||
if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) {
|
||||
return 'Le numéro de téléphone du co-parent n’est pas valide.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < d.children.length; i++) {
|
||||
final c = d.children[i];
|
||||
final label = 'Enfant ${i + 1}';
|
||||
if (!c.photoConsent) {
|
||||
return '$label : le consentement photo est obligatoire.';
|
||||
}
|
||||
if (c.isUnbornChild) {
|
||||
final due = _ddMmYyyyToIso(c.dob);
|
||||
if (due == null) {
|
||||
return '$label : indiquez une date de naissance prévisionnelle.';
|
||||
}
|
||||
if (!apiGenres.contains(c.genre)) {
|
||||
return '$label : indiquez Fille, Garçon ou Inconnu.';
|
||||
}
|
||||
} else {
|
||||
if (c.firstName.trim().length < 2) {
|
||||
return '$label : le prénom doit contenir au moins 2 caractères.';
|
||||
}
|
||||
final birth = _ddMmYyyyToIso(c.dob);
|
||||
if (birth == null) {
|
||||
return '$label : indiquez une date de naissance valide.';
|
||||
}
|
||||
if (c.genre != 'H' && c.genre != 'F') {
|
||||
return '$label : indiquez Fille ou Garçon.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> toJson(UserRegistrationData d) {
|
||||
final p1 = d.parent1;
|
||||
final tel = _normalizePhone(p1.phone);
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'email': normalizeEmailText(p1.email),
|
||||
'prenom': p1.firstName.trim(),
|
||||
'nom': p1.lastName.trim(),
|
||||
'telephone': tel,
|
||||
'acceptation_cgu': d.cguAccepted,
|
||||
'acceptation_privacy': d.cguAccepted,
|
||||
};
|
||||
|
||||
_putIfNonEmpty(body, 'adresse', p1.address.trim());
|
||||
_putIfNonEmpty(body, 'code_postal', p1.postalCode.trim());
|
||||
_putIfNonEmpty(body, 'ville', p1.city.trim());
|
||||
|
||||
final p2 = d.parent2;
|
||||
if (p2 != null &&
|
||||
p2.email.trim().isNotEmpty &&
|
||||
p2.firstName.trim().length >= 2 &&
|
||||
p2.lastName.trim().length >= 2) {
|
||||
final tel2 = _normalizePhone(p2.phone);
|
||||
final sameAddr = p2.address.trim() == p1.address.trim() &&
|
||||
p2.postalCode.trim() == p1.postalCode.trim() &&
|
||||
p2.city.trim() == p1.city.trim();
|
||||
|
||||
body['co_parent_email'] = normalizeEmailText(p2.email);
|
||||
body['co_parent_prenom'] = p2.firstName.trim();
|
||||
body['co_parent_nom'] = p2.lastName.trim();
|
||||
body['co_parent_meme_adresse'] = sameAddr;
|
||||
if (tel2.isNotEmpty) {
|
||||
body['co_parent_telephone'] = tel2;
|
||||
}
|
||||
if (!sameAddr) {
|
||||
_putIfNonEmpty(body, 'co_parent_adresse', p2.address.trim());
|
||||
_putIfNonEmpty(body, 'co_parent_code_postal', p2.postalCode.trim());
|
||||
_putIfNonEmpty(body, 'co_parent_ville', p2.city.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (d.motivationText.trim().isNotEmpty) {
|
||||
body['presentation_dossier'] = d.motivationText.trim();
|
||||
}
|
||||
|
||||
body['enfants'] = d.children.asMap().entries.map((e) {
|
||||
return _childToJson(e.value, e.key, d.parent1.lastName.trim());
|
||||
}).toList();
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||
final map = <String, dynamic>{
|
||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||
'grossesse_multiple': c.multipleBirth,
|
||||
};
|
||||
|
||||
final prenom = c.firstName.trim();
|
||||
if (prenom.length >= 2) {
|
||||
map['prenom'] = prenom;
|
||||
}
|
||||
|
||||
final nom = c.lastName.trim();
|
||||
if (nom.length >= 2) {
|
||||
map['nom'] = nom;
|
||||
} else if (parentNom.length >= 2) {
|
||||
map['nom'] = parentNom;
|
||||
}
|
||||
|
||||
if (c.isUnbornChild) {
|
||||
final due = _ddMmYyyyToIso(c.dob);
|
||||
if (due != null) {
|
||||
map['date_previsionnelle_naissance'] = due;
|
||||
}
|
||||
} else {
|
||||
final birth = _ddMmYyyyToIso(c.dob);
|
||||
if (birth != null) {
|
||||
map['date_naissance'] = birth;
|
||||
}
|
||||
}
|
||||
|
||||
final photo = _childPhotoBase64(c, index, prenom);
|
||||
if (photo != null) {
|
||||
map['photo_base64'] = photo.$1;
|
||||
map['photo_filename'] = photo.$2;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Sous-type `image/…` pour data-URL et extension côté API (`jpeg`, `png`, `gif`, `heic`, …).
|
||||
static String _imageMimeFromMagicBytes(Uint8List bytes) {
|
||||
if (bytes.length >= 8) {
|
||||
if (bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes[0] == 0xFF && bytes[1] == 0xD8) {
|
||||
return 'jpeg';
|
||||
}
|
||||
if (bytes.length >= 12 &&
|
||||
bytes[0] == 0x52 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46 &&
|
||||
bytes[3] == 0x46) {
|
||||
return 'webp';
|
||||
}
|
||||
if (bytes.length >= 6 &&
|
||||
bytes[0] == 0x47 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46 &&
|
||||
bytes[3] == 0x38 &&
|
||||
(bytes[4] == 0x37 || bytes[4] == 0x39) &&
|
||||
bytes[5] == 0x61) {
|
||||
return 'gif';
|
||||
}
|
||||
if (bytes.length >= 12 &&
|
||||
bytes[4] == 0x66 &&
|
||||
bytes[5] == 0x74 &&
|
||||
bytes[6] == 0x79 &&
|
||||
bytes[7] == 0x70) {
|
||||
final a = bytes[8], b = bytes[9], c = bytes[10], d = bytes[11];
|
||||
if ((a == 0x68 && b == 0x65 && c == 0x69 && d == 0x63) ||
|
||||
(a == 0x68 && b == 0x65 && c == 0x69 && d == 0x78) ||
|
||||
(a == 0x6d && b == 0x69 && c == 0x66 && d == 0x31) ||
|
||||
(a == 0x6d && b == 0x73 && c == 0x66 && d == 0x31)) {
|
||||
return 'heic';
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'jpeg';
|
||||
}
|
||||
|
||||
/// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible.
|
||||
static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) {
|
||||
Uint8List? bytes = c.imageBytes;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
final file = c.imageFile;
|
||||
if (file == null) return null;
|
||||
try {
|
||||
if (!file.existsSync()) return null;
|
||||
bytes = file.readAsBytesSync();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (bytes.isEmpty) return null;
|
||||
final b64 = base64Encode(bytes);
|
||||
final mime = _imageMimeFromMagicBytes(bytes);
|
||||
final safeName = prenom.isNotEmpty
|
||||
? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.$mime'
|
||||
: 'enfant_${index + 1}.$mime';
|
||||
return ('data:image/$mime;base64,$b64', safeName);
|
||||
}
|
||||
|
||||
static void _putIfNonEmpty(Map<String, dynamic> m, String key, String value) {
|
||||
if (value.isNotEmpty) {
|
||||
m[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
static String _normalizePhone(String raw) {
|
||||
var s = raw.replaceAll(RegExp(r'\s'), '');
|
||||
if (s.startsWith('0033')) {
|
||||
s = '+33${s.substring(4)}';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/// `jj/mm/aaaa` → `aaaa-mm-jj`, ou `null` si invalide.
|
||||
static String? _ddMmYyyyToIso(String ddMmYyyy) {
|
||||
if (ddMmYyyy.isEmpty) return null;
|
||||
final parts = ddMmYyyy.split('/');
|
||||
if (parts.length != 3) return null;
|
||||
final day = int.tryParse(parts[0]);
|
||||
final month = int.tryParse(parts[1]);
|
||||
final year = int.tryParse(parts[2]);
|
||||
if (day == null || month == null || year == null) return null;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -8,46 +8,127 @@ String normalizePhone(String raw) {
|
||||
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;
|
||||
if (raw.trim().isEmpty) {
|
||||
return raw;
|
||||
}
|
||||
final normalized = normalizePhone(raw);
|
||||
if (normalized.isEmpty) return 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 < normalized.length; i++) {
|
||||
if (i > 0 && i.isEven) buffer.write(' ');
|
||||
buffer.write(normalized[i]);
|
||||
for (var i = 0; i < normalizedDigits.length; i++) {
|
||||
if (i > 0 && i.isEven) {
|
||||
buffer.write(' ');
|
||||
}
|
||||
buffer.write(normalizedDigits[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
|
||||
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,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
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 buffer = StringBuffer();
|
||||
for (var i = 0; i < normalized.length; i++) {
|
||||
if (i > 0 && i.isEven) buffer.write(' ');
|
||||
buffer.write(normalized[i]);
|
||||
}
|
||||
final formatted = buffer.toString();
|
||||
final formatted = formatFrenchPhoneDigits(normalized);
|
||||
|
||||
// Conserver la position du curseur : compter les chiffres avant la sélection
|
||||
final sel = newValue.selection;
|
||||
final digitsBeforeCursor = newValue.text
|
||||
var digitsBeforeCursor = newValue.text
|
||||
.substring(0, sel.start.clamp(0, newValue.text.length))
|
||||
.replaceAll(RegExp(r'\D'), '')
|
||||
.length;
|
||||
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
|
||||
final clampedOffset = newOffset.clamp(0, formatted.length);
|
||||
if (didPrependZero) {
|
||||
digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length);
|
||||
}
|
||||
|
||||
final clampedOffset =
|
||||
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
@@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.).
|
||||
final List<TextInputFormatter> frenchPhoneInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
const FrenchPhoneNumberFormatter(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Saisie code postal français : uniquement des chiffres, au plus 5.
|
||||
final List<TextInputFormatter> kFrenchPostalCodeInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(5),
|
||||
];
|
||||
|
||||
/// Valide un code postal français (exactement 5 chiffres).
|
||||
String? validateFrenchPostalCode(String? raw, {bool allowEmpty = false}) {
|
||||
final s = raw?.trim() ?? '';
|
||||
if (s.isEmpty) {
|
||||
return allowEmpty ? null : 'Ce champ est obligatoire';
|
||||
}
|
||||
if (s.length != 5 || int.tryParse(s) == null) {
|
||||
return 'Le code postal doit comporter 5 chiffres.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user