[#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:
2026-04-11 18:07:24 +02:00
parent cde676c4f9
commit fdd1e06e77
49 changed files with 3179 additions and 828 deletions
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
@@ -182,6 +183,9 @@ class _ParametresPanelState extends State<ParametresPanel> {
hintText: 'admin@example.com',
),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
inputFormatters: const [EmailMaxLengthFormatter()],
),
actions: [
TextButton(
@@ -191,7 +195,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
FilledButton(
onPressed: () {
final t = c.text.trim();
if (t.isNotEmpty) Navigator.pop(ctx, t);
if (t.isEmpty) {
return;
}
final err = validateEmail(t, allowEmpty: true);
if (err != null) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(err)),
);
return;
}
Navigator.pop(ctx, t);
},
child: const Text('Envoyer'),
),
@@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) {
return false;
}
if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) {
return false;
}
for (final day in _days) {
final ferme = _closedByDay[day] ?? false;
@@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
TextField(
controller: _ligneFixeCtrl,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
inputFormatters: frenchPhoneInputFormatters,
decoration: const InputDecoration(
labelText: 'Ligne fixe',
hintText: '01 23 45 67 89',
@@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI.
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
@@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
],
),
)
: Image.network(
photoUrl,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
Widget build(BuildContext context) {
@@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl);
if (kDebugMode) {
debugPrint(
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
'photoUrl modèle=${e.photoUrl ?? ""} | url affichée=${photoUrl.isEmpty ? "" : photoUrl}',
);
}
final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
@@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
}
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
const photoRadius = 8.0;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(photoRadius),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
@@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: Image.network(
photoUrl,
fit: BoxFit.contain,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: width,
height: height,
errorBuilder: (_, __, ___) => ColoredBox(