Squash merge develop → master. - #138 : modale enfant paysage, zone AM/scolarisation, liens responsables - #144 : persistance consent_photo à l'inscription enfant - #143 : masquer Supprimer sur la propre fiche gestionnaire Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
9.7 KiB
Dart
300 lines
9.7 KiB
Dart
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;
|
||
}
|
||
|
||
/// Enfant pour PATCH reprise (inclut `id` si connu).
|
||
static Map<String, dynamic> childToRepriseJson(
|
||
ChildData c,
|
||
int index,
|
||
String parentNom,
|
||
) {
|
||
final map = _childToJson(c, index, parentNom);
|
||
final id = c.repriseChildId?.trim();
|
||
if (id != null && id.isNotEmpty) {
|
||
map['id'] = id;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
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,
|
||
'consent_photo': c.photoConsent,
|
||
};
|
||
|
||
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')}';
|
||
}
|
||
}
|