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:
parent
481fa66630
commit
de60a001cc
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
214
frontend/lib/utils/parent_registration_payload.dart
Normal file
214
frontend/lib/utils/parent_registration_payload.dart
Normal 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 'L’email 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 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) {
|
||||
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 '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.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')}';
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user