feat(front): inscription parent — soumission réelle vers POST /auth/register/parent (#101)

- ParentRegistrationPayload : validation client et mapping UserRegistrationData → JSON API
- AuthService.registerParent
- Étape 5 : appel API, chargement, erreurs en dialogue, succès inchangé puis /login

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-03-28 17:56:55 +01:00
parent 481fa66630
commit de60a001cc
3 changed files with 292 additions and 19 deletions

View File

@ -13,6 +13,7 @@ import '../../widgets/custom_navigation_button.dart';
import '../../widgets/personal_info_form_screen.dart';
import '../../widgets/child_card_widget.dart';
import '../../widgets/presentation_form_screen.dart';
import '../../services/auth_service.dart';
class ParentRegisterStep5Screen extends StatefulWidget {
const ParentRegisterStep5Screen({super.key});
@ -22,6 +23,36 @@ class ParentRegisterStep5Screen extends StatefulWidget {
}
class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
bool _isSubmitting = false;
Future<void> _submitRegistration(BuildContext context, UserRegistrationData data) async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
await AuthService.registerParent(data);
if (!context.mounted) return;
_showSuccessModal(context);
} catch (e) {
if (!context.mounted) return;
final msg = e.toString().replaceAll('Exception: ', '');
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Envoi impossible', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
content: Text(msg, style: GoogleFonts.merienda(fontSize: 14)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
),
],
),
);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final registrationData = Provider.of<UserRegistrationData>(context);
@ -102,12 +133,11 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Soumettre',
text: _isSubmitting ? 'Envoi…' : 'Soumettre',
style: NavigationButtonStyle.green,
onPressed: () {
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
_showConfirmationModal(context);
},
onPressed: _isSubmitting
? () {}
: () => _submitRegistration(context, registrationData),
width: double.infinity,
height: 50,
fontSize: 16,
@ -118,18 +148,20 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
),
)
else
ImageButton(
bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F),
width: 350,
height: 50,
fontSize: 18,
onPressed: () {
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
_showConfirmationModal(context);
},
),
_isSubmitting
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
)
: ImageButton(
bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F),
width: 350,
height: 50,
fontSize: 18,
onPressed: () => _submitRegistration(context, registrationData),
),
],
),
),
@ -239,14 +271,14 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
cardColor: CardColorHorizontal.green, // Changé de pink à green
textFieldHint: '',
initialText: data.motivationText,
initialCguAccepted: true, // Toujours true ici car déjà passé
initialCguAccepted: data.cguAccepted,
previousRoute: '',
onSubmit: (t, c) {},
onEdit: () => context.go('/parent-register-step4'),
);
}
void _showConfirmationModal(BuildContext context) {
void _showSuccessModal(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: false,

View File

@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import '../models/am_registration_data.dart';
import '../models/user_registration_data.dart';
import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
import '../utils/nir_utils.dart';
@ -188,6 +190,31 @@ class AuthService {
throw Exception(message);
}
/// Inscription parent complète (POST /auth/register/parent).
/// Succès : 201, pas de session rediriger vers le login.
static Future<void> registerParent(UserRegistrationData data) async {
final validationError = ParentRegistrationPayload.validateForApi(data);
if (validationError != null) {
throw Exception(validationError);
}
final body = ParentRegistrationPayload.toJson(data);
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'),
headers: ApiConfig.headers,
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
final message = _extractErrorMessage(decoded, response.statusCode);
throw Exception(message);
}
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
static String _extractErrorMessage(dynamic decoded, int statusCode) {
const fallback = 'Erreur lors de l\'inscription';

View File

@ -0,0 +1,214 @@
import 'dart:convert';
import '../models/user_registration_data.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}$');
/// 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) {
return 'Lemail du parent principal est requis.';
}
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 nest 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) {
if (p2.email.trim().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()) {
return 'Lemail 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 nest pas valide.';
}
}
}
for (var i = 0; i < d.children.length; i++) {
final c = d.children[i];
final label = 'Enfant ${i + 1}';
if (c.isUnbornChild) {
final due = _ddMmYyyyToIso(c.dob);
if (due == null) {
return '$label : indiquez une date de naissance prévisionnelle.';
}
} 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.';
}
}
}
return null;
}
static Map<String, dynamic> toJson(UserRegistrationData d) {
final p1 = d.parent1;
final tel = _normalizePhone(p1.phone);
final body = <String, dynamic>{
'email': p1.email.trim(),
'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'] = p2.email.trim();
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': 'F',
'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;
}
/// (`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;
}
}
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')}';
}
}