diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh
index 0ae987e..db139a6 100755
--- a/backend/scripts/test-register-am.sh
+++ b/backend/scripts/test-register-am.sh
@@ -1,27 +1,32 @@
#!/bin/bash
-# Test POST /auth/register/am (ticket #90)
+# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql
+# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
+# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
+#
+# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
# Usage: ./scripts/test-register-am.sh [BASE_URL]
-# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
-# ./scripts/test-register-am.sh http://localhost:3000/api/v1
+# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}"
-echo "Testing POST $BASE_URL/auth/register/am"
+echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)"
echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \
-d '{
- "email": "marie.dupont.test@ptits-pas.fr",
- "prenom": "Marie",
- "nom": "DUPONT",
+ "email": "smoke.am.curl@ptits-pas.fr",
+ "prenom": "Smoke",
+ "nom": "CURLTEST",
"telephone": "0612345678",
- "adresse": "1 rue Test",
- "code_postal": "75001",
- "ville": "Paris",
- "consentement_photo": true,
- "nir": "123456789012345",
- "numero_agrement": "AGR-2024-001",
- "capacite_accueil": 4,
+ "adresse": "1 rue Smoke",
+ "code_postal": "95870",
+ "ville": "Bezons",
+ "consentement_photo": false,
+ "date_naissance": "1986-12-15",
+ "nir": "186127500100279",
+ "numero_agrement": "AGR-SMOKE-CURL-001",
+ "capacite_accueil": 3,
+ "places_disponibles": 2,
"acceptation_cgu": true,
"acceptation_privacy": true
}'
diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts
index 0874aec..55a7b94 100644
--- a/backend/src/routes/auth/auth.service.ts
+++ b/backend/src/routes/auth/auth.service.ts
@@ -418,6 +418,12 @@ export class AuthService {
);
}
+ if (dto.places_disponibles > dto.capacite_accueil) {
+ throw new BadRequestException(
+ 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
+ );
+ }
+
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance,
diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts
index 16d0c89..2749cc4 100644
--- a/backend/src/routes/auth/dto/register-am-complet.dto.ts
+++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts
@@ -138,6 +138,17 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number;
+ @ApiProperty({
+ example: 2,
+ description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
+ minimum: 0,
+ maximum: 10,
+ })
+ @IsInt()
+ @Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
+ @Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
+ places_disponibles: number;
+
// ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================
diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql
index be8569d..6d54592 100644
--- a/database/seed/03_seed_test_data.sql
+++ b/database/seed/03_seed_test_data.sql
@@ -5,6 +5,7 @@
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
+-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
-- À exécuter après BDD.sql (init DB)
-- ============================================================
diff --git a/docs/test-data/README.md b/docs/test-data/README.md
index 7908a9e..e75665a 100644
--- a/docs/test-data/README.md
+++ b/docs/test-data/README.md
@@ -16,6 +16,8 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
## 👥 Utilisateurs de test
+**Source de vérité (identités, NIR, agréments, adresses)** : `database/seed/03_seed_test_data.sql`, complété par `utilisateurs-test.csv` et ce README. Les scripts Node `tests/scripts/register-am-*-test.mjs` reprennent les mêmes valeurs.
+
### 1. Administrateur
| Nom | Prénom | Email | Téléphone | Mobile |
@@ -49,7 +51,14 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Rôle** : `assistante_maternelle`
**Spécialité** : Bébés 0-18 mois
**Agrément** : 4 enfants
-**Places disponibles** : 2
+**Places disponibles** : 2
+**Date de naissance** : 1980-06-08
+**Lieu de naissance (inscription)** : Ajaccio (Corse, France) — NIR de test avec code département **2A**
+**NIR (fictif, clé valide)** : `280062A00100191`
+**N° d'agrément** : `AGR-2019-095001`
+**Date d'agrément** : 2019-09-01
+**Adresse** : 25 Rue de la République, 95870 Bezons
+**Script d'inscription** : `tests/scripts/register-am-dubois-test.mjs`
#### Fatima EL MANSOURI
@@ -60,7 +69,14 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Rôle** : `assistante_maternelle`
**Spécialité** : 1-3 ans
**Agrément** : 3 enfants
-**Places disponibles** : 1
+**Places disponibles** : 1
+**Date de naissance** : 1975-11-12
+**Lieu de naissance (inscription)** : exemple Casablanca (Maroc) — NIR de test avec code **99** (naissance à l'étranger)
+**NIR (fictif, clé valide)** : `275119900100102`
+**N° d'agrément** : `AGR-2017-095002`
+**Date d'agrément** : 2017-06-15
+**Adresse** : 17 Boulevard Aristide Briand, 95870 Bezons
+**Script d'inscription** : `tests/scripts/register-am-mansouri-test.mjs`
---
@@ -160,20 +176,14 @@ POST /api/v1/gestionnaires
}
```
-#### Scénario 2 : Inscription assistante maternelle
+#### Scénario 2 : Inscription assistante maternelle (complète)
-```typescript
-// Marie DUBOIS s'inscrit
-POST /api/v1/auth/register
-{
- "email": "marie.dubois@ptits-pas.fr",
- "password": "Test1234!",
- "prenom": "Marie",
- "nom": "DUBOIS",
- "telephone": "01 39 98 67 89",
- "mobile": "06 96 34 56 78",
- "role": "assistante_maternelle"
-}
+Inscription en un flux : `POST /api/v1/auth/register/am` (identité, NIR, agrément, photo optionnelle, CGU). Voir le DTO backend `RegisterAMCompletDto`.
+
+```bash
+# Jeux alignés sur le seed (mêmes NIR / dates / agréments que 03_seed_test_data.sql) :
+node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
+node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
```
#### Scénario 3 : Inscription parent
diff --git a/docs/test-data/utilisateurs-test.html b/docs/test-data/utilisateurs-test.html
index 13231a0..ba144e4 100644
--- a/docs/test-data/utilisateurs-test.html
+++ b/docs/test-data/utilisateurs-test.html
@@ -177,10 +177,12 @@
Profession
-
Agrément : 4 enfants max
+
Agrément : 4 enfants max (n° AGR-2019-095001, depuis le 01/09/2019)
Expérience : 12 ans
Spécialité : Bébés 0-18 mois
Places disponibles : 2
+
NIR (test, fictif) : 280062A00100191
+
Lieu de naissance : Ajaccio (Corse)
@@ -207,10 +209,12 @@
Profession
-
Agrément : 3 enfants max
+
Agrément : 3 enfants max (n° AGR-2017-095002, depuis le 15/06/2017)
Expérience : 15 ans
Spécialité : Enfants 1-3 ans
Places disponibles : 1
+
NIR (test, fictif) : 275119900100102
+
Lieu de naissance : à l'étranger (ex. Casablanca, Maroc — inscription)
diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart
index df92400..44604df 100644
--- a/frontend/lib/models/am_registration_data.dart
+++ b/frontend/lib/models/am_registration_data.dart
@@ -1,5 +1,8 @@
import 'package:flutter/foundation.dart';
+/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
+const int kAmCapaciteAccueilMax = 4;
+
class AmRegistrationData extends ChangeNotifier {
// Step 1: Identity Info
String firstName = '';
@@ -24,7 +27,11 @@ class AmRegistrationData extends ChangeNotifier {
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
String nir = ''; // Numéro de Sécurité Sociale
String agrementNumber = ''; // Numéro d'agrément
+ /// Date d'obtention de l'agrément — obligatoire (API `date_agrement`).
+ DateTime? agreementDate;
int? capacity; // Number of children the AM can look after
+ /// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
+ int? placesAvailable;
// Step 3: Presentation & CGU
String presentationText = '';
@@ -68,7 +75,9 @@ class AmRegistrationData extends ChangeNotifier {
// String? placeOfBirth, // Remplacé
String? nir,
String? agrementNumber,
+ DateTime? agreementDate,
int? capacity,
+ int? placesAvailable,
}) {
this.photoPath = photoPath;
this.photoBytes = photoBytes;
@@ -80,7 +89,9 @@ class AmRegistrationData extends ChangeNotifier {
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
this.nir = nir ?? this.nir;
this.agrementNumber = agrementNumber ?? this.agrementNumber;
+ this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity;
+ this.placesAvailable = placesAvailable ?? this.placesAvailable;
notifyListeners();
}
@@ -95,11 +106,11 @@ class AmRegistrationData extends ChangeNotifier {
// --- Getters for validation or display ---
bool get isStep1Complete =>
- firstName.isNotEmpty &&
- lastName.isNotEmpty &&
- streetAddress.isNotEmpty && // Modifié
- postalCode.isNotEmpty && // Nouveau
- city.isNotEmpty && // Nouveau
+ firstName.trim().length >= 2 &&
+ lastName.trim().length >= 2 &&
+ streetAddress.isNotEmpty &&
+ postalCode.isNotEmpty &&
+ city.isNotEmpty &&
phone.isNotEmpty &&
email.isNotEmpty;
// password n'est pas requis à l'inscription (défini après validation par lien email)
@@ -112,13 +123,20 @@ class AmRegistrationData extends ChangeNotifier {
!photoPath!.startsWith('assets/'));
bool get isStep2Complete =>
- (_hasUserPhoto ? photoConsent == true : true) &&
+ _hasUserPhoto &&
+ photoConsent == true &&
dateOfBirth != null &&
- birthCity.isNotEmpty &&
- birthCountry.isNotEmpty &&
- nir.isNotEmpty && // Basic check, could add validation
+ birthCity.trim().length >= 2 &&
+ birthCountry.trim().length >= 2 &&
+ nir.isNotEmpty &&
agrementNumber.isNotEmpty &&
- capacity != null && capacity! > 0;
+ agreementDate != null &&
+ capacity != null &&
+ capacity! >= 1 &&
+ capacity! <= kAmCapaciteAccueilMax &&
+ placesAvailable != null &&
+ placesAvailable! >= 0 &&
+ placesAvailable! <= capacity!;
bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire)
@@ -135,7 +153,7 @@ class AmRegistrationData extends ChangeNotifier {
'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
- 'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, '
+ 'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
}
diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart
index 86df609..66129e6 100644
--- a/frontend/lib/screens/auth/am_register_step1_screen.dart
+++ b/frontend/lib/screens/auth/am_register_step1_screen.dart
@@ -28,6 +28,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
title: 'Vos informations personnelles',
cardColor: CardColorHorizontal.blue,
initialData: initialData,
+ minPersonNameLength: 2,
previousRoute: '/register-choice',
onSubmit: (data, {hasSecondPerson, sameAddress}) {
registrationData.updateIdentityInfo(
diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart
index aba12e4..2f98b0e 100644
--- a/frontend/lib/screens/auth/am_register_step2_screen.dart
+++ b/frontend/lib/screens/auth/am_register_step2_screen.dart
@@ -23,7 +23,9 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: registrationData.birthCountry,
nir: registrationData.nir,
agrementNumber: registrationData.agrementNumber,
+ agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity,
+ placesAvailable: registrationData.placesAvailable,
);
return ProfessionalInfoFormScreen(
@@ -43,7 +45,9 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: data.birthCountry,
nir: data.nir,
agrementNumber: data.agrementNumber,
+ agreementDate: data.agreementDate,
capacity: data.capacity,
+ placesAvailable: data.placesAvailable,
);
context.go('/am-register-step3');
},
diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart
index e8405c1..90f536f 100644
--- a/frontend/lib/screens/auth/am_register_step4_screen.dart
+++ b/frontend/lib/screens/auth/am_register_step4_screen.dart
@@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State {
Future _submitAMRegistration(AmRegistrationData registrationData) async {
if (_isSubmitting) return;
+ if (!registrationData.isRegistrationComplete) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Le dossier est incomplet. Vérifiez chaque étape (photo, consentement, lieux de naissance, etc.).',
+ style: GoogleFonts.merienda(fontSize: 14),
+ ),
+ backgroundColor: Colors.red.shade700,
+ ),
+ );
+ return;
+ }
setState(() => _isSubmitting = true);
try {
await AuthService.registerAM(registrationData);
@@ -204,7 +217,9 @@ class _AmRegisterStep4ScreenState extends State {
birthCountry: data.birthCountry,
nir: data.nir,
agrementNumber: data.agrementNumber,
+ agreementDate: data.agreementDate,
capacity: data.capacity,
+ placesAvailable: data.placesAvailable,
photoConsent: data.photoConsent,
),
onSubmit: (d) {},
diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart
index 8a1fc64..77f9587 100644
--- a/frontend/lib/services/auth_service.dart
+++ b/frontend/lib/services/auth_service.dart
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
+import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
@@ -22,6 +23,17 @@ class AuthService {
return name.isEmpty ? null : name;
}
+ static String _imageMimeForBytes(Uint8List bytes) {
+ if (bytes.length >= 8 &&
+ bytes[0] == 0x89 &&
+ bytes[1] == 0x50 &&
+ bytes[2] == 0x4E &&
+ bytes[3] == 0x47) {
+ return 'image/png';
+ }
+ return 'image/jpeg';
+ }
+
/// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future login(String email, String password) async {
@@ -150,11 +162,25 @@ class AuthService {
/// Inscription AM complète (POST /auth/register/am).
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future registerAM(AmRegistrationData data) async {
+ if (data.agreementDate == null) {
+ throw Exception('La date d\'obtention de l\'agrément est requise.');
+ }
+ if (data.placesAvailable == null) {
+ throw Exception('Le nombre de places disponibles est requis.');
+ }
+ final lieuVille = data.birthCity.trim();
+ final lieuPays = data.birthCountry.trim();
+ if (lieuVille.length < 2 || lieuPays.length < 2) {
+ throw Exception(
+ 'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
+ );
+ }
String? photoBase64;
String? photoFilename;
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
- photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}';
+ final mime = _imageMimeForBytes(data.photoBytes!);
+ photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
final fn = (data.photoFilename ?? '').trim();
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
} else if (data.photoPath != null &&
@@ -165,7 +191,8 @@ class AuthService {
final file = File(data.photoPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
- photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
+ final mime = _imageMimeForBytes(bytes);
+ photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
photoFilename =
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
}
@@ -173,6 +200,10 @@ class AuthService {
}
}
+ if (photoBase64 == null || photoBase64.isEmpty) {
+ throw Exception('Une photo de profil est requise pour finaliser l’inscription.');
+ }
+
final body = {
'email': data.email,
'prenom': data.firstName,
@@ -181,17 +212,20 @@ class AuthService {
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null,
- if (photoBase64 != null) 'photo_base64': photoBase64,
- if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg',
+ 'photo_base64': photoBase64,
+ 'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null,
- 'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
- 'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
+ 'lieu_naissance_ville': lieuVille,
+ 'lieu_naissance_pays': lieuPays,
'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber,
+ 'date_agrement':
+ '${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1,
+ 'places_disponibles': data.placesAvailable!,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted,
diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart
index 0ca0324..ac64e05 100644
--- a/frontend/lib/utils/nir_utils.dart
+++ b/frontend/lib/utils/nir_utils.dart
@@ -57,20 +57,22 @@ String formatNir(String raw) {
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
}
-/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
+/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
bool _isFormatValid(String raw) {
- if (raw.length != 15) return false;
- final dept = raw.substring(5, 7);
- final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
- if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
- return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
+ final r = raw.toUpperCase();
+ if (r.length != 15) return false;
+ return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r);
}
-/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
+/// Clé INSEE : 97 - (NIR13 mod 97). Corse : 2A→19, 2B→20 (aligné backend).
int _controlKey(String raw13) {
- String n = raw13;
- if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
- n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
+ String n;
+ if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') {
+ n = '${raw13.substring(0, 5)}19${raw13.substring(7)}';
+ } else if (raw13.length >= 7 && raw13.substring(5, 7) == '2B') {
+ n = '${raw13.substring(0, 5)}20${raw13.substring(7)}';
+ } else {
+ n = '${raw13.substring(0, 5)}${raw13.substring(5, 7)}${raw13.substring(7)}';
}
final big = int.tryParse(n);
if (big == null) return -1;
diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart
index afad14e..1afbf6e 100644
--- a/frontend/lib/widgets/child_card_widget.dart
+++ b/frontend/lib/widgets/child_card_widget.dart
@@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
import '../models/card_assets.dart';
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
-import 'hover_relief_widget.dart';
+import 'registration_photo_slot.dart';
import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
@@ -16,13 +16,12 @@ const String _photoConsentTooltip =
'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.';
-/// Cadre photo (centre transparent), même dossier que `photo.png`.
-const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
-
bool _hasChildPhoto(ChildData c) {
- final b = c.imageBytes;
- if (b != null && b.isNotEmpty) return true;
- return c.imageFile != null;
+ return registrationPhotoSlotHasImage(
+ imageBytes: c.imageBytes,
+ imageFile: c.imageFile,
+ imagePathOrAsset: null,
+ );
}
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
@@ -194,11 +193,6 @@ class _ChildCardWidgetState extends State {
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
- final Color initialPhotoShadow =
- Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow);
- final Color hoverPhotoShadow =
- Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow);
-
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile
? double.infinity
@@ -222,14 +216,15 @@ class _ChildCardWidgetState extends State {
Column(
mainAxisSize: MainAxisSize.min,
children: [
- _buildEditablePhotoArea(
- context: context,
- config: config,
+ RegistrationPhotoSlot(
+ side: photoSide,
scaleFactor: scaleFactor,
- photoSide: photoSide,
- childData: widget.childData,
- initialPhotoShadow: initialPhotoShadow,
- hoverPhotoShadow: hoverPhotoShadow,
+ imageBytes: widget.childData.imageBytes,
+ imageFile: widget.childData.imageFile,
+ onTapPick: !config.isReadonly ? widget.onPickImage : null,
+ onClear: !config.isReadonly ? widget.onClearImage : null,
+ baseShadowColor: baseCardColorForShadow,
+ isMobile: config.isMobile,
),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
@@ -321,96 +316,6 @@ class _ChildCardWidgetState extends State {
);
}
- Widget _buildEditablePhotoArea({
- required BuildContext context,
- required DisplayConfig config,
- required double scaleFactor,
- required double photoSide,
- required ChildData childData,
- required Color initialPhotoShadow,
- required Color hoverPhotoShadow,
- }) {
- final canInteract = !config.isReadonly;
- final hasPhoto = _hasChildPhoto(childData);
- final outerRadius = BorderRadius.circular(10 * scaleFactor);
- // Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor).
- final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
-
- return Stack(
- clipBehavior: Clip.none,
- alignment: Alignment.center,
- children: [
- HoverReliefWidget(
- onPressed: canInteract ? widget.onPickImage : null,
- borderRadius: outerRadius,
- initialElevation: 10,
- hoverElevation: 16,
- initialShadowColor: initialPhotoShadow,
- hoverShadowColor: hoverPhotoShadow,
- // Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué).
- clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
- child: SizedBox(
- width: photoSide,
- height: photoSide,
- child: !hasPhoto
- ? ClipRRect(
- borderRadius: outerRadius,
- child: Center(
- child: Image.asset(
- 'assets/images/photo.png',
- fit: BoxFit.contain,
- width: photoSide,
- height: photoSide,
- ),
- ),
- )
- : Stack(
- fit: StackFit.expand,
- children: [
- // Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
- Positioned.fill(
- child: ClipRRect(
- borderRadius: photoClipRadius,
- child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
- ),
- ),
- Positioned.fill(
- child: Image.asset(
- _photoSketchFrameAsset,
- fit: BoxFit.fill,
- errorBuilder: (context, error, stackTrace) =>
- const SizedBox.shrink(),
- ),
- ),
- ],
- ),
- ),
- ),
- if (hasPhoto && canInteract)
- Positioned(
- top: 8 * scaleFactor,
- right: 8 * scaleFactor,
- child: Material(
- color: Colors.transparent,
- child: InkWell(
- onTap: widget.onClearImage,
- customBorder: const CircleBorder(),
- child: Padding(
- padding: const EdgeInsets.all(4),
- child: Image.asset(
- 'assets/images/cross.png',
- width: config.isMobile ? 26 : 30,
- height: config.isMobile ? 26 : 30,
- fit: BoxFit.contain,
- ),
- ),
- ),
- ),
- ),
- ],
- );
- }
-
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart
index dae6364..e8e01a9 100644
--- a/frontend/lib/widgets/custom_app_text_field.dart
+++ b/frontend/lib/widgets/custom_app_text_field.dart
@@ -27,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon;
final double labelFontSize;
final double inputFontSize;
+ /// Espace vertical entre le libellé et la zone de saisie.
+ final double labelFieldSpacing;
final bool showLabel;
final Iterable? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged? onFieldSubmitted;
+ /// Souvent appelé par la touche « suivant » du clavier (flèche), là où [onFieldSubmitted] ne l’est pas.
+ final VoidCallback? onEditingComplete;
final List? inputFormatters;
final bool autocorrect;
final bool enableSuggestions;
@@ -56,9 +60,11 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
+ this.labelFieldSpacing = 6.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
+ this.onEditingComplete,
this.inputFormatters,
this.autocorrect = true,
this.enableSuggestions = true,
@@ -105,7 +111,7 @@ class _CustomAppTextFieldState extends State {
fontWeight: FontWeight.w500,
),
),
- const SizedBox(height: 6),
+ SizedBox(height: widget.labelFieldSpacing),
],
// Pas de hauteur fixe sur le TextFormField : le message d’erreur du
// validateur s’affiche en dessous ; un SizedBox fixe le masquait.
@@ -141,6 +147,7 @@ class _CustomAppTextFieldState extends State {
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
+ onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled,
readOnly: widget.readOnly,
onTap: widget.onTap,
diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart
index 7222811..7ea3d94 100644
--- a/frontend/lib/widgets/nir_text_field.dart
+++ b/frontend/lib/widgets/nir_text_field.dart
@@ -8,6 +8,9 @@ import 'custom_app_text_field.dart';
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget {
final TextEditingController controller;
+ final FocusNode? focusNode;
+ /// Si non null, impose l’ordre Tab parmi les descendants (ex. après « Pays de naissance »).
+ final double? focusTraversalOrder;
final String labelText;
final String hintText;
final String? Function(String?)? validator;
@@ -18,12 +21,15 @@ class NirTextField extends StatelessWidget {
final bool enabled;
final bool readOnly;
final CustomAppTextFieldStyle style;
+ final double labelFieldSpacing;
const NirTextField({
super.key,
required this.controller,
+ this.focusNode,
+ this.focusTraversalOrder,
this.labelText = 'N° Sécurité Sociale (NIR)',
- this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)',
+ this.hintText = '13 chiffres + clé',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
@@ -32,12 +38,14 @@ class NirTextField extends StatelessWidget {
this.enabled = true,
this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige,
+ this.labelFieldSpacing = 6.0,
});
@override
Widget build(BuildContext context) {
- return CustomAppTextField(
+ final field = CustomAppTextField(
controller: controller,
+ focusNode: focusNode,
labelText: labelText,
hintText: hintText,
fieldWidth: fieldWidth,
@@ -50,6 +58,15 @@ class NirTextField extends StatelessWidget {
enabled: enabled,
readOnly: readOnly,
style: style,
+ labelFieldSpacing: labelFieldSpacing,
);
+ final o = focusTraversalOrder;
+ if (o != null) {
+ return FocusTraversalOrder(
+ order: NumericFocusOrder(o),
+ child: field,
+ );
+ }
+ return field;
}
}
diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart
index 181cc82..94c9888 100644
--- a/frontend/lib/widgets/personal_info_form_screen.dart
+++ b/frontend/lib/widgets/personal_info_form_screen.dart
@@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
+ /// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
+ final int? minPersonNameLength;
const PersonalInfoFormScreen({
super.key,
@@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
this.referenceAddressData,
this.embedContentOnly = false,
this.onEdit,
+ this.minPersonNameLength,
});
@override
@@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State {
return validateEmail(value, allowEmpty: true);
}
+ /// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
+ String? _validatePersonNameWithMinLength(String? value) {
+ final min = widget.minPersonNameLength;
+ if (min == null) return null;
+ final t = (value ?? '').trim();
+ if (t.isEmpty) return 'Ce champ est obligatoire';
+ if (t.length < min) return 'Au moins $min caractères';
+ return null;
+ }
+
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
@@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State {
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
+ fieldValidator: widget.minPersonNameLength != null
+ ? _validatePersonNameWithMinLength
+ : null,
),
),
const SizedBox(width: 20),
@@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State {
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
+ fieldValidator: widget.minPersonNameLength != null
+ ? _validatePersonNameWithMinLength
+ : null,
),
),
],
@@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State {
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
+ fieldValidator: widget.minPersonNameLength != null
+ ? _validatePersonNameWithMinLength
+ : null,
),
const SizedBox(height: 12),
@@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State {
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
+ fieldValidator: widget.minPersonNameLength != null
+ ? _validatePersonNameWithMinLength
+ : null,
),
const SizedBox(height: 12),
diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart
index 1f7f99c..6225cd1 100644
--- a/frontend/lib/widgets/professional_info_form_screen.dart
+++ b/frontend/lib/widgets/professional_info_form_screen.dart
@@ -7,15 +7,18 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'dart:math' as math;
import 'dart:io';
+import '../models/am_registration_data.dart' show kAmCapaciteAccueilMax;
import '../models/card_assets.dart';
import '../config/display_config.dart';
import '../utils/nir_utils.dart';
+import '../utils/name_format_utils.dart';
import 'custom_app_text_field.dart';
import 'nir_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart';
import 'custom_navigation_button.dart';
+import 'registration_photo_slot.dart';
/// Données pour le formulaire d'informations professionnelles
class ProfessionalInfoData {
@@ -29,7 +32,10 @@ class ProfessionalInfoData {
final String birthCountry;
final String nir;
final String agrementNumber;
+ /// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
+ final DateTime? agreementDate;
final int? capacity;
+ final int? placesAvailable;
ProfessionalInfoData({
this.photoPath,
@@ -42,7 +48,9 @@ class ProfessionalInfoData {
this.birthCountry = '',
this.nir = '',
this.agrementNumber = '',
+ this.agreementDate,
this.capacity,
+ this.placesAvailable,
});
}
@@ -81,15 +89,35 @@ class ProfessionalInfoFormScreen extends StatefulWidget {
class _ProfessionalInfoFormScreenState extends State {
final _formKey = GlobalKey();
-
+
+ /// Ordre Tab explicite : sans cela, la zone photo (InkWell) peut s’insérer entre pays et NIR.
+ static const double _kTabPhotoPick = 1;
+ static const double _kTabDateBirth = 2;
+ static const double _kTabBirthCity = 3;
+ static const double _kTabBirthCountry = 4;
+ static const double _kTabNir = 5;
+ static const double _kTabAgrement = 6;
+ static const double _kTabAgreementDate = 7;
+ static const double _kTabCapacity = 8;
+ static const double _kTabPlaces = 9;
+ static const double _kTabChevronNext = 10;
+ static const double _kTabChevronPrev = 11;
+
final _dateOfBirthController = TextEditingController();
final _birthCityController = TextEditingController();
final _birthCountryController = TextEditingController();
final _nirController = TextEditingController();
final _agrementController = TextEditingController();
+ final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController();
-
+ final _placesAvailableController = TextEditingController();
+
+ FocusNode? _birthCityFocus;
+ FocusNode? _birthCountryFocus;
+ FocusNode? _nirFocus;
+
DateTime? _selectedDate;
+ DateTime? _selectedAgreementDate;
String? _photoPathFramework;
File? _photoFile;
Uint8List? _photoBytes;
@@ -118,26 +146,89 @@ class _ProfessionalInfoFormScreenState extends State
final nirRaw = nirToRaw(data.nir);
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
_agrementController.text = data.agrementNumber;
+ _selectedAgreementDate = data.agreementDate;
+ _agreementDateController.text = data.agreementDate != null
+ ? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
+ : '';
_capacityController.text = data.capacity?.toString() ?? '';
+ _placesAvailableController.text = data.placesAvailable?.toString() ?? '';
_photoPathFramework = data.photoPath;
_photoFile = data.photoFile;
_photoBytes = data.photoBytes;
_photoFilename = data.photoFilename;
_photoConsent = data.photoConsent;
}
+
+ if (widget.mode == DisplayMode.editable) {
+ _birthCityFocus = FocusNode();
+ _birthCountryFocus = FocusNode();
+ _nirFocus = FocusNode();
+ _birthCityFocus!.addListener(_onBirthCityFocusChange);
+ _birthCountryFocus!.addListener(_onBirthCountryFocusChange);
+ }
+ }
+
+ void _onBirthCityFocusChange() {
+ if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
+ _applyPlaceNameFormat(_birthCityController);
+ }
+
+ void _onBirthCountryFocusChange() {
+ if (_birthCountryFocus == null || _birthCountryFocus!.hasFocus) return;
+ _applyPlaceNameFormat(_birthCountryController);
+ }
+
+ void _applyPlaceNameFormat(TextEditingController controller) {
+ final formatted = formatPersonNameCase(controller.text);
+ if (formatted == controller.text) return;
+ controller.value = TextEditingValue(
+ text: formatted,
+ selection: TextSelection.collapsed(offset: formatted.length),
+ );
}
@override
void dispose() {
+ _birthCityFocus?.removeListener(_onBirthCityFocusChange);
+ _birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
+ _birthCityFocus?.dispose();
+ _birthCountryFocus?.dispose();
+ _nirFocus?.dispose();
_dateOfBirthController.dispose();
_birthCityController.dispose();
_birthCountryController.dispose();
_nirController.dispose();
_agrementController.dispose();
+ _agreementDateController.dispose();
_capacityController.dispose();
+ _placesAvailableController.dispose();
super.dispose();
}
+ String? _validateBirthCity(String? v) {
+ final t = (v ?? '').trim();
+ if (t.isEmpty) return 'Ville requise';
+ if (t.length < 2) return 'Au moins 2 caractères';
+ return null;
+ }
+
+ String? _validateBirthCountry(String? v) {
+ final t = (v ?? '').trim();
+ if (t.isEmpty) return 'Pays requis';
+ if (t.length < 2) return 'Au moins 2 caractères';
+ return null;
+ }
+
+ String? _validatePlacesAvailable(String? v) {
+ if (v == null || v.isEmpty) return 'Places disponibles requises';
+ final n = int.tryParse(v);
+ if (n == null || n < 0) return 'Nombre entre 0 et la capacité';
+ if (n > kAmCapaciteAccueilMax) return 'Maximum $kAmCapaciteAccueilMax';
+ final cap = int.tryParse(_capacityController.text);
+ if (cap != null && n > cap) return 'Ne peut pas dépasser la capacité';
+ return null;
+ }
+
Future _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
@@ -154,6 +245,22 @@ class _ProfessionalInfoFormScreenState extends State
}
}
+ Future _selectAgreementDate(BuildContext context) async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: _selectedAgreementDate ?? DateTime.now().subtract(const Duration(days: 365 * 5)),
+ firstDate: DateTime(1970, 1),
+ lastDate: DateTime.now(),
+ locale: const Locale('fr', 'FR'),
+ );
+ if (picked != null) {
+ setState(() {
+ _selectedAgreementDate = picked;
+ _agreementDateController.text = DateFormat('dd/MM/yyyy').format(picked);
+ });
+ }
+ }
+
Future _pickPhoto() async {
if (widget.onPickPhoto != null) {
await widget.onPickPhoto!();
@@ -190,9 +297,27 @@ class _ProfessionalInfoFormScreenState extends State
} catch (_) {}
}
+ void _clearRegistrationPhoto() {
+ setState(() {
+ _photoBytes = null;
+ _photoFile = null;
+ _photoPathFramework = null;
+ _photoFilename = null;
+ _photoConsent = false;
+ });
+ }
+
void _submitForm() {
+ _applyPlaceNameFormat(_birthCityController);
+ _applyPlaceNameFormat(_birthCountryController);
if (_formKey.currentState!.validate()) {
- if (_hasRealPhoto && !_photoConsent) {
+ if (!_hasRealPhoto) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Veuillez ajouter une photo de profil.')),
+ );
+ return;
+ }
+ if (!_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
);
@@ -206,11 +331,13 @@ class _ProfessionalInfoFormScreenState extends State
photoFilename: _photoFilename,
photoConsent: _photoConsent,
dateOfBirth: _selectedDate,
- birthCity: _birthCityController.text,
- birthCountry: _birthCountryController.text,
+ birthCity: _birthCityController.text.trim(),
+ birthCountry: _birthCountryController.text.trim(),
nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text,
+ agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text),
+ placesAvailable: int.tryParse(_placesAvailableController.text),
);
widget.onSubmit(data);
@@ -226,82 +353,89 @@ class _ProfessionalInfoFormScreenState extends State
return _buildCard(context, config, screenSize);
}
- return Scaffold(
- body: Stack(
- children: [
- Positioned.fill(
- child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
- ),
- Center(
- child: SingleChildScrollView(
- padding: const EdgeInsets.symmetric(vertical: 40.0),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(
- widget.stepText,
- style: GoogleFonts.merienda(
- fontSize: config.isMobile ? 13 : 16,
- color: Colors.black54,
+ return FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(),
+ child: Scaffold(
+ body: Stack(
+ children: [
+ Positioned.fill(
+ child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
+ ),
+ Center(
+ child: SingleChildScrollView(
+ padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ widget.stepText,
+ style: GoogleFonts.merienda(
+ fontSize: config.isMobile ? 13 : 16,
+ color: Colors.black54,
+ ),
),
- ),
- SizedBox(height: config.isMobile ? 6 : 10),
- Text(
- widget.title,
- style: GoogleFonts.merienda(
- fontSize: config.isMobile ? 18 : 24,
- fontWeight: FontWeight.bold,
- color: Colors.black87,
+ SizedBox(height: config.isMobile ? 6 : 10),
+ Text(
+ widget.title,
+ style: GoogleFonts.merienda(
+ fontSize: config.isMobile ? 18 : 24,
+ fontWeight: FontWeight.bold,
+ color: Colors.black87,
+ ),
+ textAlign: TextAlign.center,
),
- textAlign: TextAlign.center,
- ),
- SizedBox(height: config.isMobile ? 16 : 30),
- _buildCard(context, config, screenSize),
-
- // Boutons mobile sous la carte
- if (config.isMobile) ...[
- const SizedBox(height: 20),
- _buildMobileButtons(context, config, screenSize),
- const SizedBox(height: 10),
+ SizedBox(height: config.isMobile ? 16 : 20),
+ _buildCard(context, config, screenSize),
+
+ // Boutons mobile sous la carte
+ if (config.isMobile) ...[
+ const SizedBox(height: 20),
+ _buildMobileButtons(context, config, screenSize),
+ const SizedBox(height: 10),
+ ],
],
- ],
+ ),
),
),
- ),
- // Chevrons desktop uniquement
+ // Chevrons desktop : visuellement sur les côtés ; ordre Tab 10–11 = après « Places disponibles ».
if (!config.isMobile) ...[
- // Chevron Gauche (Retour)
Positioned(
top: screenSize.height / 2 - 20,
left: 40,
- child: IconButton(
- icon: Transform(
- alignment: Alignment.center,
- transform: Matrix4.rotationY(math.pi),
- child: Image.asset('assets/images/chevron_right.png', height: 40),
+ child: FocusTraversalOrder(
+ order: const NumericFocusOrder(_kTabChevronPrev),
+ child: IconButton(
+ icon: Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.rotationY(math.pi),
+ child: Image.asset('assets/images/chevron_right.png', height: 40),
+ ),
+ onPressed: () {
+ if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(widget.previousRoute);
+ }
+ },
+ tooltip: 'Précédent',
),
- onPressed: () {
- if (context.canPop()) {
- context.pop();
- } else {
- context.go(widget.previousRoute);
- }
- },
- tooltip: 'Précédent',
),
),
- // Chevron Droit (Suivant)
Positioned(
top: screenSize.height / 2 - 20,
right: 40,
- child: IconButton(
- icon: Image.asset('assets/images/chevron_right.png', height: 40),
- onPressed: _submitForm,
- tooltip: 'Suivant',
+ child: FocusTraversalOrder(
+ order: const NumericFocusOrder(_kTabChevronNext),
+ child: IconButton(
+ icon: Image.asset('assets/images/chevron_right.png', height: 40),
+ onPressed: _submitForm,
+ tooltip: 'Suivant',
+ ),
),
),
],
],
+ ),
),
);
}
@@ -326,7 +460,7 @@ class _ProfessionalInfoFormScreenState extends State
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
- vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
+ vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28),
horizontal: config.isMobile ? 24 : 50,
),
decoration: BoxDecoration(
@@ -354,7 +488,7 @@ class _ProfessionalInfoFormScreenState extends State
),
textAlign: TextAlign.center,
),
- const SizedBox(height: 20),
+ SizedBox(height: config.isMobile ? 20.0 : 14.0),
],
config.isMobile
? _buildMobileFields(context, config)
@@ -476,7 +610,7 @@ class _ProfessionalInfoFormScreenState extends State
),
],
),
- const SizedBox(height: 18),
+ const SizedBox(height: 10),
// Contenu
Expanded(
@@ -491,42 +625,31 @@ class _ProfessionalInfoFormScreenState extends State
children: [
AspectRatio(
aspectRatio: 1,
- child: Container(
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(18),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.1),
- blurRadius: 10,
- offset: const Offset(0, 5),
- ),
- ],
- ),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(18),
- child: _photoBytes != null && _photoBytes!.isNotEmpty
- ? Image.memory(_photoBytes!, fit: BoxFit.cover)
- : _photoFile != null
- ? Image.file(_photoFile!, fit: BoxFit.cover)
- : (_photoPathFramework != null &&
- _photoPathFramework!.startsWith('assets/')
- ? Image.asset(_photoPathFramework!, fit: BoxFit.contain)
- : Image.asset('assets/images/photo.png', fit: BoxFit.contain)),
- ),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final side = constraints.biggest.shortestSide;
+ return RegistrationPhotoSlot(
+ side: side,
+ imageBytes: _photoBytes,
+ imageFile: _photoFile,
+ imagePathOrAsset: _photoPathFramework,
+ baseShadowColor: Colors.green.shade300,
+ );
+ },
),
),
- const SizedBox(height: 10),
+ const SizedBox(height: 5),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
- onChanged: (v) {}, // Readonly
+ onChanged: (_) {}, // Readonly
checkboxSize: 22.0,
fontSize: 14.0,
),
],
),
),
- const SizedBox(width: 32),
+ const SizedBox(width: 24),
// CHAMPS (2/3) - Layout optimisé compact
Expanded(
@@ -534,32 +657,56 @@ class _ProfessionalInfoFormScreenState extends State
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- // Ligne 1 : Ville + Pays
- Row(
- children: [
- Expanded(child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
- const SizedBox(width: 16),
- Expanded(child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
- ],
- ),
- const SizedBox(height: 12),
-
- // Ligne 2 : Date + NIR (NIR prend plus de place si possible ou 50/50)
+ // Ligne 1 : Date de naissance + Ville
Row(
children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16),
+ Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
+ ],
+ ),
+ const SizedBox(height: 5),
+
+ // Ligne 2 : Pays + NIR
+ Row(
+ children: [
+ Expanded(flex: 2, child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
+ const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
],
),
- const SizedBox(height: 12),
+ const SizedBox(height: 5),
- // Ligne 3 : Agrément + Capacité
+ // Ligne 3 : N° agrément + Date d'obtention
Row(
children: [
- Expanded(flex: 3, child: _buildReadonlyField('N° Agrément', _agrementController.text)),
+ Expanded(
+ flex: 3,
+ child: _buildReadonlyField('N° d\'agrément', _agrementController.text),
+ ),
const SizedBox(width: 16),
- Expanded(flex: 2, child: _buildReadonlyField('Capacité', _capacityController.text)),
+ Expanded(
+ flex: 2,
+ child: _buildReadonlyField(
+ 'Date d\'obtention de l\'agrément',
+ _agreementDateController.text.isNotEmpty ? _agreementDateController.text : '—',
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 5),
+ Row(
+ children: [
+ Expanded(
+ child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: _buildReadonlyField(
+ 'Places disponibles',
+ _placesAvailableController.text,
+ ),
+ ),
],
),
],
@@ -588,10 +735,10 @@ class _ProfessionalInfoFormScreenState extends State
children: [
Text(
label,
- style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
+ style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
- const SizedBox(height: 4),
+ const SizedBox(height: 3),
Container(
width: double.infinity,
height: 45.0, // Hauteur réduite pour compacter
@@ -605,7 +752,7 @@ class _ProfessionalInfoFormScreenState extends State
),
child: Text(
value.isNotEmpty ? value : '-',
- style: GoogleFonts.merienda(fontSize: 16.0),
+ style: GoogleFonts.merienda(fontSize: 14.0),
overflow: TextOverflow.ellipsis,
),
),
@@ -615,7 +762,7 @@ class _ProfessionalInfoFormScreenState extends State
/// Layout DESKTOP : Photo à gauche, champs à droite
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
- final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
+ final double verticalSpacing = config.isReadonly ? 8.0 : 14.0;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -628,27 +775,11 @@ class _ProfessionalInfoFormScreenState extends State
width: 300,
child: _buildPhotoSection(context, config),
),
- const SizedBox(width: 30),
+ const SizedBox(width: 22),
// Champs à droite
Expanded(
child: Column(
children: [
- _buildField(
- config: config,
- label: 'Ville de naissance',
- controller: _birthCityController,
- hint: 'Votre ville de naissance',
- validator: (v) => v!.isEmpty ? 'Ville requise' : null,
- ),
- SizedBox(height: verticalSpacing),
- _buildField(
- config: config,
- label: 'Pays de naissance',
- controller: _birthCountryController,
- hint: 'Votre pays de naissance',
- validator: (v) => v!.isEmpty ? 'Pays requis' : null,
- ),
- SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Date de naissance',
@@ -658,6 +789,30 @@ class _ProfessionalInfoFormScreenState extends State
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
+ focusTraversalOrder: _kTabDateBirth,
+ ),
+ SizedBox(height: verticalSpacing),
+ _buildField(
+ config: config,
+ label: 'Ville de naissance',
+ controller: _birthCityController,
+ hint: 'Ex. Ajaccio, Paris, Casablanca…',
+ validator: _validateBirthCity,
+ focusNode: _birthCityFocus,
+ focusTraversalOrder: _kTabBirthCity,
+ ),
+ SizedBox(height: verticalSpacing),
+ _buildField(
+ config: config,
+ label: 'Pays de naissance',
+ controller: _birthCountryController,
+ hint: 'Ex. France, Maroc…',
+ validator: _validateBirthCountry,
+ focusNode: _birthCountryFocus,
+ focusTraversalOrder: _kTabBirthCountry,
+ textInputAction: TextInputAction.next,
+ onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
+ onEditingComplete: () => _nirFocus?.requestFocus(),
),
],
),
@@ -667,13 +822,17 @@ class _ProfessionalInfoFormScreenState extends State
SizedBox(height: verticalSpacing),
NirTextField(
controller: _nirController,
+ focusNode: _nirFocus,
+ focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
- labelFontSize: config.isMobile ? 15.0 : 22.0,
- inputFontSize: config.isMobile ? 14.0 : 20.0,
+ labelFontSize: config.isMobile ? 15.0 : 20.0,
+ inputFontSize: config.isMobile ? 14.0 : 18.0,
+ labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
),
SizedBox(height: verticalSpacing),
Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
@@ -682,22 +841,61 @@ class _ProfessionalInfoFormScreenState extends State
controller: _agrementController,
hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
+ focusTraversalOrder: _kTabAgrement,
),
),
- const SizedBox(width: 20),
+ const SizedBox(width: 14),
+ Expanded(
+ child: _buildField(
+ config: config,
+ label: 'Date d\'obtention de l\'agrément',
+ controller: _agreementDateController,
+ hint: 'JJ/MM/AAAA',
+ readOnly: true,
+ onTap: () => _selectAgreementDate(context),
+ suffixIcon: Icons.calendar_today,
+ validator: (_) =>
+ _selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
+ focusTraversalOrder: _kTabAgreementDate,
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: verticalSpacing),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
Expanded(
child: _buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
- hint: 'Ex: 3',
+ hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
- if (n == null || n <= 0) return 'Nombre invalide';
+ if (n == null || n < 1) {
+ return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
+ }
+ if (n > kAmCapaciteAccueilMax) {
+ return 'Maximum $kAmCapaciteAccueilMax';
+ }
return null;
},
+ focusTraversalOrder: _kTabCapacity,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: _buildField(
+ config: config,
+ label: 'Places disponibles',
+ controller: _placesAvailableController,
+ hint: 'Entre 0 et la capacité',
+ keyboardType: TextInputType.number,
+ validator: _validatePlacesAvailable,
+ focusTraversalOrder: _kTabPlaces,
),
),
],
@@ -715,24 +913,6 @@ class _ProfessionalInfoFormScreenState extends State
_buildPhotoSection(context, config),
const SizedBox(height: 20),
- _buildField(
- config: config,
- label: 'Ville de naissance',
- controller: _birthCityController,
- hint: 'Votre ville de naissance',
- validator: (v) => v!.isEmpty ? 'Ville requise' : null,
- ),
- const SizedBox(height: 12),
-
- _buildField(
- config: config,
- label: 'Pays de naissance',
- controller: _birthCountryController,
- hint: 'Votre pays de naissance',
- validator: (v) => v!.isEmpty ? 'Pays requis' : null,
- ),
- const SizedBox(height: 12),
-
_buildField(
config: config,
label: 'Date de naissance',
@@ -742,11 +922,39 @@ class _ProfessionalInfoFormScreenState extends State
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
+ focusTraversalOrder: _kTabDateBirth,
+ ),
+ const SizedBox(height: 12),
+
+ _buildField(
+ config: config,
+ label: 'Ville de naissance',
+ controller: _birthCityController,
+ hint: 'Ex. Ajaccio, Paris, Casablanca…',
+ validator: _validateBirthCity,
+ focusNode: _birthCityFocus,
+ focusTraversalOrder: _kTabBirthCity,
+ ),
+ const SizedBox(height: 12),
+
+ _buildField(
+ config: config,
+ label: 'Pays de naissance',
+ controller: _birthCountryController,
+ hint: 'Ex. France, Maroc…',
+ validator: _validateBirthCountry,
+ focusNode: _birthCountryFocus,
+ focusTraversalOrder: _kTabBirthCountry,
+ textInputAction: TextInputAction.next,
+ onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
+ onEditingComplete: () => _nirFocus?.requestFocus(),
),
const SizedBox(height: 12),
NirTextField(
controller: _nirController,
+ focusNode: _nirFocus,
+ focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity,
fieldHeight: 45.0,
labelFontSize: 15.0,
@@ -760,72 +968,90 @@ class _ProfessionalInfoFormScreenState extends State
controller: _agrementController,
hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
+ focusTraversalOrder: _kTabAgrement,
+ ),
+ const SizedBox(height: 12),
+ _buildField(
+ config: config,
+ label: 'Date d\'obtention de l\'agrément',
+ controller: _agreementDateController,
+ hint: 'JJ/MM/AAAA',
+ readOnly: true,
+ onTap: () => _selectAgreementDate(context),
+ suffixIcon: Icons.calendar_today,
+ validator: (_) =>
+ _selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
+ focusTraversalOrder: _kTabAgreementDate,
),
const SizedBox(height: 12),
-
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
- hint: 'Ex: 3',
+ hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
- if (n == null || n <= 0) return 'Nombre invalide';
+ if (n == null || n < 1) {
+ return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
+ }
+ if (n > kAmCapaciteAccueilMax) {
+ return 'Maximum $kAmCapaciteAccueilMax';
+ }
return null;
},
+ focusTraversalOrder: _kTabCapacity,
+ ),
+ const SizedBox(height: 12),
+ _buildField(
+ config: config,
+ label: 'Places disponibles',
+ controller: _placesAvailableController,
+ hint: 'Entre 0 et la capacité',
+ keyboardType: TextInputType.number,
+ validator: _validatePlacesAvailable,
+ focusTraversalOrder: _kTabPlaces,
),
],
);
}
- /// Section photo + checkbox
+ /// Section photo + checkbox (même logique visuelle que les enfants : cadre, croix).
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
- final Color baseCardColorForShadow = Colors.green.shade300;
- final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
- final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
-
- ImageProvider? currentImageProvider;
- if (_photoBytes != null && _photoBytes!.isNotEmpty) {
- currentImageProvider = MemoryImage(_photoBytes!);
- } else if (_photoFile != null) {
- currentImageProvider = FileImage(_photoFile!);
- } else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
- currentImageProvider = AssetImage(_photoPathFramework!);
- }
-
final photoSize = config.isMobile ? 200.0 : 270.0;
+ final scaleFactor = config.isMobile ? 0.9 : 1.1;
+
+ final slot = RegistrationPhotoSlot(
+ side: photoSize,
+ scaleFactor: scaleFactor,
+ imageBytes: _photoBytes,
+ imageFile: _photoFile,
+ imagePathOrAsset: _photoPathFramework,
+ onTapPick: config.isReadonly ? null : _pickPhoto,
+ onClear: config.isReadonly ? null : _clearRegistrationPhoto,
+ baseShadowColor: Colors.green.shade300,
+ isMobile: config.isMobile,
+ );
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
- HoverReliefWidget(
- onPressed: _pickPhoto,
- borderRadius: BorderRadius.circular(10.0),
- initialShadowColor: initialPhotoShadow,
- hoverShadowColor: hoverPhotoShadow,
- child: SizedBox(
- height: photoSize,
- width: photoSize,
- child: Container(
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- image: currentImageProvider != null
- ? DecorationImage(image: currentImageProvider, fit: BoxFit.cover)
- : null,
- ),
- child: currentImageProvider == null
- ? Image.asset('assets/images/photo.png', fit: BoxFit.contain)
- : null,
- ),
+ if (config.isReadonly)
+ slot
+ else
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(_kTabPhotoPick),
+ child: slot,
),
- ),
- const SizedBox(height: 10),
+ SizedBox(height: config.isMobile ? 10.0 : 6.0),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
- onChanged: (val) => setState(() => _photoConsent = val == true),
+ onChanged: (val) {
+ if (config.isReadonly) return;
+ setState(() => _photoConsent = val == true);
+ },
),
],
);
@@ -843,6 +1069,11 @@ class _ProfessionalInfoFormScreenState extends State
IconData? suffixIcon,
String? Function(String?)? validator,
List? inputFormatters,
+ FocusNode? focusNode,
+ double? focusTraversalOrder,
+ TextInputAction? textInputAction,
+ ValueChanged? onFieldSubmitted,
+ VoidCallback? onEditingComplete,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@@ -851,21 +1082,34 @@ class _ProfessionalInfoFormScreenState extends State
value: controller.text,
);
} else {
- return CustomAppTextField(
+ final field = CustomAppTextField(
controller: controller,
+ focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
- labelFontSize: config.isMobile ? 15.0 : 22.0,
- inputFontSize: config.isMobile ? 14.0 : 20.0,
+ labelFontSize: config.isMobile ? 15.0 : 20.0,
+ inputFontSize: config.isMobile ? 14.0 : 18.0,
+ labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
keyboardType: keyboardType ?? TextInputType.text,
readOnly: readOnly,
onTap: onTap,
suffixIcon: suffixIcon,
validator: validator,
inputFormatters: inputFormatters,
+ textInputAction: textInputAction,
+ onFieldSubmitted: onFieldSubmitted,
+ onEditingComplete: onEditingComplete,
);
+ final o = focusTraversalOrder;
+ if (o != null) {
+ return FocusTraversalOrder(
+ order: NumericFocusOrder(o),
+ child: field,
+ );
+ }
+ return field;
}
}
diff --git a/frontend/lib/widgets/registration_photo_slot.dart b/frontend/lib/widgets/registration_photo_slot.dart
new file mode 100644
index 0000000..7dde753
--- /dev/null
+++ b/frontend/lib/widgets/registration_photo_slot.dart
@@ -0,0 +1,169 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter/foundation.dart' show kIsWeb;
+import 'package:flutter/material.dart';
+
+import 'hover_relief_widget.dart';
+
+/// Cadre « croquis » par-dessus la photo (même ressource que les cartes enfant).
+const String kRegistrationPhotoFrameAsset = 'assets/images/photo_frame.png';
+
+/// Indique si une photo « réelle » est présente (hors seul placeholder asset).
+bool registrationPhotoSlotHasImage({
+ Uint8List? imageBytes,
+ File? imageFile,
+ String? imagePathOrAsset,
+}) {
+ if (imageBytes != null && imageBytes.isNotEmpty) return true;
+ if (imageFile != null) return true;
+ if (imagePathOrAsset == null || imagePathOrAsset.isEmpty) return false;
+ return !imagePathOrAsset.startsWith('assets/');
+}
+
+/// Zone photo uniforme : cadre gris / placeholder, tap pour choisir, croix pour retirer, cadre dessin si photo.
+///
+/// Utilisé inscription **enfants** ([ChildCardWidget]) et **AM** ([ProfessionalInfoFormScreen]).
+class RegistrationPhotoSlot extends StatelessWidget {
+ final double side;
+ final double scaleFactor;
+ final Uint8List? imageBytes;
+ final File? imageFile;
+ /// Chemin fichier local (hors `assets/`) ou URI asset `assets/...`.
+ final String? imagePathOrAsset;
+ final VoidCallback? onTapPick;
+ final VoidCallback? onClear;
+ final Color baseShadowColor;
+ final String placeholderAsset;
+ final bool isMobile;
+
+ const RegistrationPhotoSlot({
+ super.key,
+ required this.side,
+ this.scaleFactor = 1.0,
+ this.imageBytes,
+ this.imageFile,
+ this.imagePathOrAsset,
+ this.onTapPick,
+ this.onClear,
+ required this.baseShadowColor,
+ this.placeholderAsset = 'assets/images/photo.png',
+ this.isMobile = false,
+ });
+
+ bool get _hasImage => registrationPhotoSlotHasImage(
+ imageBytes: imageBytes,
+ imageFile: imageFile,
+ imagePathOrAsset: imagePathOrAsset,
+ );
+
+ Widget _buildImage({required BoxFit fit}) {
+ final b = imageBytes;
+ if (b != null && b.isNotEmpty) {
+ return Image.memory(b, fit: fit);
+ }
+ final f = imageFile;
+ if (f != null) {
+ return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
+ }
+ final p = imagePathOrAsset;
+ if (p != null && p.isNotEmpty) {
+ if (p.startsWith('assets/')) {
+ return Image.asset(p, fit: fit);
+ }
+ if (!kIsWeb) {
+ try {
+ final file = File(p);
+ if (file.existsSync()) {
+ return Image.file(file, fit: fit);
+ }
+ } catch (_) {}
+ }
+ }
+ return Image.asset(placeholderAsset, fit: BoxFit.contain);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final canPick = onTapPick != null;
+ final canClear = onClear != null && _hasImage;
+ final outerRadius = BorderRadius.circular(10 * scaleFactor);
+ final photoClipRadius = BorderRadius.circular(side * 0.14);
+ final initialPhotoShadow =
+ Color.alphaBlend(Colors.black.withOpacity(0.22), baseShadowColor);
+ final hoverPhotoShadow =
+ Color.alphaBlend(Colors.black.withOpacity(0.32), baseShadowColor);
+
+ return Stack(
+ clipBehavior: Clip.none,
+ alignment: Alignment.center,
+ children: [
+ HoverReliefWidget(
+ onPressed: canPick ? onTapPick : null,
+ borderRadius: outerRadius,
+ initialElevation: 10,
+ hoverElevation: 16,
+ initialShadowColor: initialPhotoShadow,
+ hoverShadowColor: hoverPhotoShadow,
+ clipBehavior: _hasImage ? Clip.none : Clip.antiAlias,
+ child: SizedBox(
+ width: side,
+ height: side,
+ child: !_hasImage
+ ? ClipRRect(
+ borderRadius: outerRadius,
+ child: Center(
+ child: Image.asset(
+ placeholderAsset,
+ fit: BoxFit.contain,
+ width: side,
+ height: side,
+ ),
+ ),
+ )
+ : Stack(
+ fit: StackFit.expand,
+ children: [
+ Positioned.fill(
+ child: ClipRRect(
+ borderRadius: photoClipRadius,
+ child: _buildImage(fit: BoxFit.cover),
+ ),
+ ),
+ Positioned.fill(
+ child: Image.asset(
+ kRegistrationPhotoFrameAsset,
+ fit: BoxFit.fill,
+ errorBuilder: (context, error, stackTrace) =>
+ const SizedBox.shrink(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (canClear)
+ Positioned(
+ top: 8 * scaleFactor,
+ right: 8 * scaleFactor,
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onClear,
+ customBorder: const CircleBorder(),
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Image.asset(
+ 'assets/images/cross.png',
+ width: isMobile ? 26 : 30,
+ height: isMobile ? 26 : 30,
+ fit: BoxFit.contain,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/tests/scripts/register-am-dubois-test.mjs b/tests/scripts/register-am-dubois-test.mjs
new file mode 100644
index 0000000..9cdcd6c
--- /dev/null
+++ b/tests/scripts/register-am-dubois-test.mjs
@@ -0,0 +1,128 @@
+/**
+ * POST /api/v1/auth/register/am — jeu de test officiel Marie DUBOIS (docs/test-data + seed).
+ * Email : marie.dubois@ptits-pas.fr
+ *
+ * Photo attendue : tests/ressources/photos/dubois-marie.png
+ * Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
+ * (réduction JPEG locale via sharp-cli si besoin, comme les autres scripts lourds)
+ *
+ * Usage : node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import https from 'https';
+import http from 'http';
+import { execSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..', '..');
+const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
+
+function shrinkToJpeg(inputPath, label) {
+ const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
+ const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`;
+ execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true });
+ return out;
+}
+
+function toDataUri(filePath) {
+ const buf = fs.readFileSync(filePath);
+ const ext = path.extname(filePath).toLowerCase();
+ const mime =
+ ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
+ return `data:${mime};base64,${buf.toString('base64')}`;
+}
+
+const photoSrc = path.join(photosDir, 'dubois-marie.png');
+
+let photoPath;
+try {
+ console.error('Préparation photo (sharp-cli)…');
+ photoPath = shrinkToJpeg(photoSrc, 'am-dubois');
+
+ const body = {
+ email: 'marie.dubois@ptits-pas.fr',
+ prenom: 'Marie',
+ nom: 'DUBOIS',
+ telephone: '0696345678',
+ adresse: '25 Rue de la République',
+ code_postal: '95870',
+ ville: 'Bezons',
+ photo_base64: toDataUri(photoPath),
+ photo_filename: 'marie_dubois.jpg',
+ consentement_photo: true,
+ date_naissance: '1980-06-08',
+ lieu_naissance_ville: 'Ajaccio',
+ lieu_naissance_pays: 'France',
+ nir: '280062A00100191',
+ numero_agrement: 'AGR-2019-095001',
+ date_agrement: '2019-09-01',
+ capacite_accueil: 4,
+ places_disponibles: 2,
+ biographie:
+ "Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. " +
+ 'Accueil bienveillant et cadre sécurisant. 2 places disponibles.',
+ acceptation_cgu: true,
+ acceptation_privacy: true,
+ };
+
+ const json = JSON.stringify(body);
+ const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
+ const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
+ const url = new URL('/api/v1/auth/register/am', `${base.protocol}//${base.host}`);
+
+ const opts = {
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'Content-Length': Buffer.byteLength(json, 'utf8'),
+ },
+ };
+
+ const lib = url.protocol === 'https:' ? https : http;
+
+ console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
+
+ const req = lib.request(opts, (res) => {
+ let data = '';
+ res.on('data', (c) => {
+ data += c;
+ });
+ res.on('end', () => {
+ console.log('HTTP', res.statusCode);
+ try {
+ const j = JSON.parse(data);
+ console.log(JSON.stringify(j, null, 2));
+ } catch {
+ console.log(data.slice(0, 4000));
+ }
+ });
+ });
+
+ req.on('error', (e) => {
+ console.error('Erreur réseau:', e.message);
+ process.exit(1);
+ });
+
+ req.setTimeout(120000, () => {
+ req.destroy();
+ console.error('Timeout 120s');
+ process.exit(1);
+ });
+
+ req.write(json);
+ req.end();
+} finally {
+ try {
+ if (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/tests/scripts/register-am-mansouri-test.mjs b/tests/scripts/register-am-mansouri-test.mjs
new file mode 100644
index 0000000..1e52d62
--- /dev/null
+++ b/tests/scripts/register-am-mansouri-test.mjs
@@ -0,0 +1,127 @@
+/**
+ * POST /api/v1/auth/register/am — jeu de test officiel Fatima EL MANSOURI (docs/test-data + seed).
+ * Email : fatima.elmansouri@ptits-pas.fr
+ *
+ * Photo attendue : tests/ressources/photos/mansouri-fatima.png
+ * Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
+ *
+ * Usage : node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import https from 'https';
+import http from 'http';
+import { execSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..', '..');
+const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
+
+function shrinkToJpeg(inputPath, label) {
+ const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
+ const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`;
+ execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true });
+ return out;
+}
+
+function toDataUri(filePath) {
+ const buf = fs.readFileSync(filePath);
+ const ext = path.extname(filePath).toLowerCase();
+ const mime =
+ ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
+ return `data:${mime};base64,${buf.toString('base64')}`;
+}
+
+const photoSrc = path.join(photosDir, 'mansouri-fatima.png');
+
+let photoPath;
+try {
+ console.error('Préparation photo (sharp-cli)…');
+ photoPath = shrinkToJpeg(photoSrc, 'am-mansouri');
+
+ const body = {
+ email: 'fatima.elmansouri@ptits-pas.fr',
+ prenom: 'Fatima',
+ nom: 'EL MANSOURI',
+ telephone: '0675456789',
+ adresse: '17 Boulevard Aristide Briand',
+ code_postal: '95870',
+ ville: 'Bezons',
+ photo_base64: toDataUri(photoPath),
+ photo_filename: 'fatima_elmansouri.jpg',
+ consentement_photo: true,
+ date_naissance: '1975-11-12',
+ lieu_naissance_ville: 'Casablanca',
+ lieu_naissance_pays: 'Maroc',
+ nir: '275119900100102',
+ numero_agrement: 'AGR-2017-095002',
+ date_agrement: '2017-06-15',
+ capacite_accueil: 3,
+ places_disponibles: 1,
+ biographie:
+ "Assistante maternelle expérimentée. Née à l'étranger. Spécialité 1-3 ans. " +
+ 'Accueil à la journée. 1 place disponible.',
+ acceptation_cgu: true,
+ acceptation_privacy: true,
+ };
+
+ const json = JSON.stringify(body);
+ const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
+ const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
+ const url = new URL('/api/v1/auth/register/am', `${base.protocol}//${base.host}`);
+
+ const opts = {
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'Content-Length': Buffer.byteLength(json, 'utf8'),
+ },
+ };
+
+ const lib = url.protocol === 'https:' ? https : http;
+
+ console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
+
+ const req = lib.request(opts, (res) => {
+ let data = '';
+ res.on('data', (c) => {
+ data += c;
+ });
+ res.on('end', () => {
+ console.log('HTTP', res.statusCode);
+ try {
+ const j = JSON.parse(data);
+ console.log(JSON.stringify(j, null, 2));
+ } catch {
+ console.log(data.slice(0, 4000));
+ }
+ });
+ });
+
+ req.on('error', (e) => {
+ console.error('Erreur réseau:', e.message);
+ process.exit(1);
+ });
+
+ req.setTimeout(120000, () => {
+ req.destroy();
+ console.error('Timeout 120s');
+ process.exit(1);
+ });
+
+ req.write(json);
+ req.end();
+} finally {
+ try {
+ if (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/tests/scripts/register-parent-durand-rousseau-test.mjs b/tests/scripts/register-parent-durand-rousseau-test.mjs
index 171d179..8dcfcbb 100644
--- a/tests/scripts/register-parent-durand-rousseau-test.mjs
+++ b/tests/scripts/register-parent-durand-rousseau-test.mjs
@@ -2,7 +2,7 @@
* POST /api/v1/auth/register/parent — jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed).
* Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
*
- * Les PNG dans ressources/Photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
+ * Les PNG dans tests/ressources/photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
* via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
*
* Usage : node tests/scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..', '..');
-const photosDir = path.join(repoRoot, 'ressources', 'Photos');
+const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
function shrinkToJpeg(inputPath, label) {
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
@@ -41,8 +41,8 @@ const presentationDossier =
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
"Merci pour l'étude de notre dossier.";
-const chloeSrc = path.join(photosDir, 'G_Chloé.png');
-const hugoSrc = path.join(photosDir, 'G_Hugo.png');
+const chloeSrc = path.join(photosDir, 'rousseau-chloe.png');
+const hugoSrc = path.join(photosDir, 'rousseau-hugo.png');
let chloeJpg;
let hugoJpg;
diff --git a/tests/scripts/register-parent-lecomte-test.mjs b/tests/scripts/register-parent-lecomte-test.mjs
index 674e5c1..a03a7ab 100644
--- a/tests/scripts/register-parent-lecomte-test.mjs
+++ b/tests/scripts/register-parent-lecomte-test.mjs
@@ -12,8 +12,7 @@ import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const repoRoot = path.join(__dirname, '..', '..');
-const photosDir = path.join(repoRoot, 'ressources', 'Photos');
+const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
@@ -40,7 +39,7 @@ const body = {
nom: 'LECOMTE',
date_naissance: '2023-04-15',
genre: 'H',
- photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')),
+ photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')),
photo_filename: 'maxime_lecomte.png',
grossesse_multiple: false,
},
diff --git a/tests/scripts/register-parent-martin-test.mjs b/tests/scripts/register-parent-martin-test.mjs
index acfcf77..0232b38 100644
--- a/tests/scripts/register-parent-martin-test.mjs
+++ b/tests/scripts/register-parent-martin-test.mjs
@@ -13,8 +13,7 @@ import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const repoRoot = path.join(__dirname, '..', '..');
-const photosDir = path.join(repoRoot, 'ressources', 'Photos');
+const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
@@ -46,7 +45,7 @@ const body = {
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
- photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')),
+ photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')),
photo_filename: 'emma_martin.png',
grossesse_multiple: true,
},
@@ -55,7 +54,7 @@ const body = {
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'H',
- photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')),
+ photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')),
photo_filename: 'noah_martin.png',
grossesse_multiple: true,
},
@@ -64,7 +63,7 @@ const body = {
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
- photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')),
+ photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')),
photo_filename: 'lea_martin.png',
grossesse_multiple: true,
},