feat(inscription): email accusé réception, photos enfants, formulaires identité
- Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API - Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés - Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field Made-with: Cursor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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).
|
||||
@@ -14,9 +16,13 @@ class ParentRegistrationPayload {
|
||||
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
|
||||
static String? validateForApi(UserRegistrationData d) {
|
||||
final p1 = d.parent1;
|
||||
if (p1.email.trim().isEmpty) {
|
||||
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.';
|
||||
}
|
||||
@@ -41,12 +47,16 @@ class ParentRegistrationPayload {
|
||||
p2.lastName.trim().isNotEmpty ||
|
||||
p2.phone.trim().isNotEmpty;
|
||||
if (any) {
|
||||
if (p2.email.trim().isEmpty ||
|
||||
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 (p2.email.trim().toLowerCase() == p1.email.trim().toLowerCase()) {
|
||||
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);
|
||||
@@ -114,7 +124,7 @@ class ParentRegistrationPayload {
|
||||
p2.postalCode.trim() == p1.postalCode.trim() &&
|
||||
p2.city.trim() == p1.city.trim();
|
||||
|
||||
body['co_parent_email'] = p2.email.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;
|
||||
@@ -142,7 +152,7 @@ class ParentRegistrationPayload {
|
||||
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': false,
|
||||
'grossesse_multiple': c.multipleBirth,
|
||||
};
|
||||
|
||||
final prenom = c.firstName.trim();
|
||||
@@ -180,20 +190,40 @@ class ParentRegistrationPayload {
|
||||
|
||||
/// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible.
|
||||
static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) {
|
||||
final file = c.imageFile;
|
||||
if (file == null) return null;
|
||||
try {
|
||||
if (!file.existsSync()) return null;
|
||||
final bytes = file.readAsBytesSync();
|
||||
if (bytes.isEmpty) return null;
|
||||
final b64 = base64Encode(bytes);
|
||||
final safeName = prenom.isNotEmpty
|
||||
? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.jpg'
|
||||
: 'enfant_${index + 1}.jpg';
|
||||
return ('data:image/jpeg;base64,$b64', safeName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
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);
|
||||
var mime = 'jpeg';
|
||||
if (bytes.length >= 8) {
|
||||
if (bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
mime = 'png';
|
||||
} else if (bytes[0] == 0xFF && bytes[1] == 0xD8) {
|
||||
mime = 'jpeg';
|
||||
} else if (bytes.length >= 12 &&
|
||||
bytes[0] == 0x52 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46 &&
|
||||
bytes[3] == 0x46) {
|
||||
mime = 'webp';
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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