From 01c1be8f1673cd65802921795f2d7067c2e25c0a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Apr 2026 21:41:19 +0200 Subject: [PATCH 1/6] feat(am): inscription photo (picker, bytes web, photo_filename) (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfessionalInfoFormScreen : ImagePicker par défaut, MemoryImage, consentement si photo réelle - AmRegistrationData : photoBytes + photoFilename ; récap étape 4 - registerAM : base64 + filename, erreurs réseau/JSON comme parents - docs: 23_LISTE-TICKETS — issue #120 Made-with: Cursor --- docs/23_LISTE-TICKETS.md | 1 + frontend/lib/models/am_registration_data.dart | 26 +++--- .../auth/am_register_step2_screen.dart | 27 +++---- .../auth/am_register_step4_screen.dart | 5 +- frontend/lib/services/auth_service.dart | 68 ++++++++++++---- .../professional_info_form_screen.dart | 80 +++++++++++++++---- 6 files changed, 147 insertions(+), 60 deletions(-) diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 9f12ad3..6e4b516 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -101,6 +101,7 @@ | 116 | Rattachement parent – frontend | Ouvert | | 117 | Évolution du cahier des charges | Ouvert | | 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé | +| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | Ouvert | *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index b660210..df92400 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -14,7 +14,9 @@ class AmRegistrationData extends ChangeNotifier { // bool photoConsent = false; // Déplacé ou géré à l'étape 2 // Step 2: Professional Info - String? photoPath; // Ajouté pour l'étape 2 + String? photoPath; // Chemin fichier local (hors web) si pas d’octets en mémoire + Uint8List? photoBytes; // Galerie / web (ImagePicker) + String? photoFilename; // Nom pour l’API (ex. image.jpg) bool photoConsent = false; // Ajouté pour l'étape 2 DateTime? dateOfBirth; String birthCity = ''; // Nouveau @@ -57,6 +59,8 @@ class AmRegistrationData extends ChangeNotifier { void updateProfessionalInfo({ String? photoPath, + Uint8List? photoBytes, + String? photoFilename, bool? photoConsent, DateTime? dateOfBirth, String? birthCity, // Nouveau @@ -66,10 +70,9 @@ class AmRegistrationData extends ChangeNotifier { String? agrementNumber, int? capacity, }) { - // Allow setting photoPath to null explicitly - if (photoPath != null || this.photoPath != null) { - this.photoPath = photoPath; - } + this.photoPath = photoPath; + this.photoBytes = photoBytes; + this.photoFilename = photoFilename; this.photoConsent = photoConsent ?? this.photoConsent; this.dateOfBirth = dateOfBirth ?? this.dateOfBirth; this.birthCity = birthCity ?? this.birthCity; // Nouveau @@ -101,12 +104,15 @@ class AmRegistrationData extends ChangeNotifier { email.isNotEmpty; // password n'est pas requis à l'inscription (défini après validation par lien email) + /// Photo réelle (pas seulement un placeholder asset). + bool get _hasUserPhoto => + (photoBytes != null && photoBytes!.isNotEmpty) || + (photoPath != null && + photoPath!.isNotEmpty && + !photoPath!.startsWith('assets/')); + bool get isStep2Complete => - // photoConsent is mandatory if a photo is system-required, otherwise optional. - // For now, let's assume if photoPath is present, consent should ideally be true. - // Or, make consent always mandatory if photo section exists. - // Based on new mockup, photo is present, so consent might be implicitly or explicitly needed. - (photoPath != null ? photoConsent == true : true) && // Ajuster selon la logique de consentement désirée + (_hasUserPhoto ? photoConsent == true : true) && dateOfBirth != null && birthCity.isNotEmpty && birthCountry.isNotEmpty && diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 362a317..aba12e4 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -6,27 +6,17 @@ import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; import '../../widgets/professional_info_form_screen.dart'; -class AmRegisterStep2Screen extends StatefulWidget { +class AmRegisterStep2Screen extends StatelessWidget { const AmRegisterStep2Screen({super.key}); - @override - State createState() => _AmRegisterStep2ScreenState(); -} - -class _AmRegisterStep2ScreenState extends State { - String? _photoPathFramework; - - Future _pickPhoto() async { - // TODO: brancher ImagePicker ; ne pas préremplir de chemin factice - } - @override Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - - // Préparer les données initiales - ProfessionalInfoData initialData = ProfessionalInfoData( + + final initialData = ProfessionalInfoData( photoPath: registrationData.photoPath, + photoBytes: registrationData.photoBytes, + photoFilename: registrationData.photoFilename, photoConsent: registrationData.photoConsent, dateOfBirth: registrationData.dateOfBirth, birthCity: registrationData.birthCity, @@ -42,10 +32,11 @@ class _AmRegisterStep2ScreenState extends State { cardColor: CardColorHorizontal.green, initialData: initialData, previousRoute: '/am-register-step1', - onPickPhoto: _pickPhoto, onSubmit: (data) { registrationData.updateProfessionalInfo( - photoPath: _photoPathFramework ?? data.photoPath, + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, photoConsent: data.photoConsent, dateOfBirth: data.dateOfBirth, birthCity: data.birthCity, @@ -58,4 +49,4 @@ class _AmRegisterStep2ScreenState extends State { }, ); } -} \ No newline at end of file +} diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 93c0bf7..e8405c1 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -196,8 +196,9 @@ class _AmRegisterStep4ScreenState extends State { title: 'Informations professionnelles', cardColor: CardColorHorizontal.green, initialData: ProfessionalInfoData( - // TODO: Gérer photoPath vs photoFile correctement - photoPath: null, // Pas d'accès facile au fichier ici, on verra + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, dateOfBirth: data.dateOfBirth, birthCity: data.birthCity, birthCountry: data.birthCountry, diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index ef4c32d..8a1fc64 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:io'; + +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; @@ -13,6 +15,13 @@ import '../utils/nir_utils.dart'; class AuthService { static const String _currentUserKey = 'current_user'; + static String? _basenameFromPath(String path) { + final s = path.replaceAll(r'\', '/'); + final i = s.lastIndexOf('/'); + final name = i >= 0 ? s.substring(i + 1) : s; + return name.isEmpty ? null : name; + } + /// Connexion de l'utilisateur /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire static Future login(String email, String password) async { @@ -142,17 +151,29 @@ class AuthService { /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login. static Future registerAM(AmRegistrationData data) async { String? photoBase64; - if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) { - try { - final file = File(data.photoPath!); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}'; - } - } catch (_) {} + String? photoFilename; + + if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { + photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}'; + final fn = (data.photoFilename ?? '').trim(); + photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg'; + } else if (data.photoPath != null && + data.photoPath!.isNotEmpty && + !data.photoPath!.startsWith('assets/')) { + if (!kIsWeb) { + try { + final file = File(data.photoPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}'; + photoFilename = + _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg'; + } + } catch (_) {} + } } - final body = { + final body = { 'email': data.email, 'prenom': data.firstName, 'nom': data.lastName, @@ -161,6 +182,7 @@ class AuthService { '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', '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')}' @@ -175,11 +197,29 @@ class AuthService { 'acceptation_privacy': data.cguAccepted, }; - final response = await http.post( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), - headers: ApiConfig.headers, - body: jsonEncode(body), - ); + final String encodedBody; + try { + encodedBody = jsonEncode(body); + } catch (_) { + throw Exception( + 'Impossible de préparer l’envoi (données trop volumineuses ou invalides). ' + 'Réessayez avec une photo plus légère.', + ); + } + + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), + headers: ApiConfig.headers, + body: encodedBody, + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, ' + 'un pare-feu ou un bloqueur, puis réessayez.', + ); + } if (response.statusCode == 200 || response.statusCode == 201) { return; diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index b50273b..1f7f99c 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'dart:math' as math; import 'dart:io'; @@ -19,6 +21,8 @@ import 'custom_navigation_button.dart'; class ProfessionalInfoData { final String? photoPath; final File? photoFile; + final Uint8List? photoBytes; + final String? photoFilename; final bool photoConsent; final DateTime? dateOfBirth; final String birthCity; @@ -30,6 +34,8 @@ class ProfessionalInfoData { ProfessionalInfoData({ this.photoPath, this.photoFile, + this.photoBytes, + this.photoFilename, this.photoConsent = false, this.dateOfBirth, this.birthCity = '', @@ -86,8 +92,17 @@ class _ProfessionalInfoFormScreenState extends State DateTime? _selectedDate; String? _photoPathFramework; File? _photoFile; + Uint8List? _photoBytes; + String? _photoFilename; bool _photoConsent = false; + bool get _hasRealPhoto => + (_photoBytes != null && _photoBytes!.isNotEmpty) || + _photoFile != null || + (_photoPathFramework != null && + _photoPathFramework!.isNotEmpty && + !_photoPathFramework!.startsWith('assets/')); + @override void initState() { super.initState(); @@ -106,6 +121,8 @@ class _ProfessionalInfoFormScreenState extends State _capacityController.text = data.capacity?.toString() ?? ''; _photoPathFramework = data.photoPath; _photoFile = data.photoFile; + _photoBytes = data.photoBytes; + _photoFilename = data.photoFilename; _photoConsent = data.photoConsent; } } @@ -140,18 +157,42 @@ class _ProfessionalInfoFormScreenState extends State Future _pickPhoto() async { if (widget.onPickPhoto != null) { await widget.onPickPhoto!(); - } else { - // Comportement par défaut : utiliser un asset de test - setState(() { - _photoPathFramework = 'assets/images/icon_assmat.png'; - _photoFile = null; - }); + return; } + + final picker = ImagePicker(); + try { + final XFile? picked = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 75, + maxWidth: 1200, + maxHeight: 1200, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (bytes.isEmpty) return; + + File? file; + if (!kIsWeb) { + try { + final f = File(picked.path); + if (await f.exists()) file = f; + } catch (_) {} + } + + final name = picked.name.trim(); + setState(() { + _photoBytes = bytes; + _photoFile = file; + _photoPathFramework = null; + _photoFilename = name.isNotEmpty ? name : 'photo_am.jpg'; + }); + } catch (_) {} } void _submitForm() { if (_formKey.currentState!.validate()) { - if (_photoPathFramework != null && !_photoConsent) { + if (_hasRealPhoto && !_photoConsent) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')), ); @@ -159,8 +200,10 @@ class _ProfessionalInfoFormScreenState extends State } final data = ProfessionalInfoData( - photoPath: _photoPathFramework, + photoPath: _photoBytes != null ? null : _photoPathFramework, photoFile: _photoFile, + photoBytes: _photoBytes, + photoFilename: _photoFilename, photoConsent: _photoConsent, dateOfBirth: _selectedDate, birthCity: _birthCityController.text, @@ -169,7 +212,7 @@ class _ProfessionalInfoFormScreenState extends State agrementNumber: _agrementController.text, capacity: int.tryParse(_capacityController.text), ); - + widget.onSubmit(data); } } @@ -461,11 +504,14 @@ class _ProfessionalInfoFormScreenState extends State ), child: ClipRRect( borderRadius: BorderRadius.circular(18), - child: _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: _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)), ), ), ), @@ -741,7 +787,9 @@ class _ProfessionalInfoFormScreenState extends State final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); ImageProvider? currentImageProvider; - if (_photoFile != null) { + 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!); @@ -777,7 +825,7 @@ class _ProfessionalInfoFormScreenState extends State AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', value: _photoConsent, - onChanged: (val) => setState(() => _photoConsent = val ?? false), + onChanged: (val) => setState(() => _photoConsent = val == true), ), ], ); From 12bf85b7bdcfd9e013174d322f630b6eea29d941 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 11:40:41 +0200 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20d=C3=A9placer=20les=20scripts=20d'?= =?UTF-8?q?inscription=20parent=20vers=20tests/scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repoRoot corrigé (../..) ; usage: node tests/scripts/register-parent-*.mjs Made-with: Cursor --- .../scripts}/register-parent-durand-rousseau-test.mjs | 4 ++-- {scripts => tests/scripts}/register-parent-lecomte-test.mjs | 4 ++-- {scripts => tests/scripts}/register-parent-martin-test.mjs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) rename {scripts => tests/scripts}/register-parent-durand-rousseau-test.mjs (97%) rename {scripts => tests/scripts}/register-parent-lecomte-test.mjs (95%) rename {scripts => tests/scripts}/register-parent-martin-test.mjs (94%) diff --git a/scripts/register-parent-durand-rousseau-test.mjs b/tests/scripts/register-parent-durand-rousseau-test.mjs similarity index 97% rename from scripts/register-parent-durand-rousseau-test.mjs rename to tests/scripts/register-parent-durand-rousseau-test.mjs index ece7507..171d179 100644 --- a/scripts/register-parent-durand-rousseau-test.mjs +++ b/tests/scripts/register-parent-durand-rousseau-test.mjs @@ -5,7 +5,7 @@ * Les PNG dans 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 scripts/register-parent-durand-rousseau-test.mjs [BASE_URL] + * Usage : node tests/scripts/register-parent-durand-rousseau-test.mjs [BASE_URL] */ import fs from 'fs'; @@ -17,7 +17,7 @@ import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.join(__dirname, '..'); +const repoRoot = path.join(__dirname, '..', '..'); const photosDir = path.join(repoRoot, 'ressources', 'Photos'); function shrinkToJpeg(inputPath, label) { diff --git a/scripts/register-parent-lecomte-test.mjs b/tests/scripts/register-parent-lecomte-test.mjs similarity index 95% rename from scripts/register-parent-lecomte-test.mjs rename to tests/scripts/register-parent-lecomte-test.mjs index 5f9c3f3..674e5c1 100644 --- a/scripts/register-parent-lecomte-test.mjs +++ b/tests/scripts/register-parent-lecomte-test.mjs @@ -2,7 +2,7 @@ * POST /api/v1/auth/register/parent — jeu de test officiel David LECOMTE (père isolé). * Email : david.lecomte@ptits-pas.fr * - * Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL] + * Usage : node tests/scripts/register-parent-lecomte-test.mjs [BASE_URL] */ import fs from 'fs'; @@ -12,7 +12,7 @@ import http from 'http'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.join(__dirname, '..'); +const repoRoot = path.join(__dirname, '..', '..'); const photosDir = path.join(repoRoot, 'ressources', 'Photos'); function toDataUri(filePath) { diff --git a/scripts/register-parent-martin-test.mjs b/tests/scripts/register-parent-martin-test.mjs similarity index 94% rename from scripts/register-parent-martin-test.mjs rename to tests/scripts/register-parent-martin-test.mjs index a1253eb..acfcf77 100644 --- a/scripts/register-parent-martin-test.mjs +++ b/tests/scripts/register-parent-martin-test.mjs @@ -2,8 +2,8 @@ * POST /api/v1/auth/register/parent — jeu de test officiel famille MARTIN (docs/test-data + seed). * Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr * - * Usage : node scripts/register-parent-martin-test.mjs [BASE_URL] - * Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr + * Usage : node tests/scripts/register-parent-martin-test.mjs [BASE_URL] + * Ex. : node tests/scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr */ import fs from 'fs'; @@ -13,7 +13,7 @@ import http from 'http'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.join(__dirname, '..'); +const repoRoot = path.join(__dirname, '..', '..'); const photosDir = path.join(repoRoot, 'ressources', 'Photos'); function toDataUri(filePath) { From 58607cdbc9cceb410c881c76513894ba752be9fa Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 12:43:54 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(inscription=20AM=20#120):=20UI=20?= =?UTF-8?q?=C3=A9tape=20pro,=20photo=20unifi=C3=A9e,=20scripts=20et=20donn?= =?UTF-8?q?=C3=A9es=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panneau AM : validation NIR alignée API, date d’agrément requise côté app, capacité 1–10, n° agrément + date sur une ligne, ville/pays formatés au blur. - Widget RegistrationPhotoSlot (cadre, croix) partagé avec les cartes enfant. - AuthService : MIME PNG/JPEG pour la photo ; payload date_agrement. - Scripts register-am-dubois / mansouri ; chemins tests/ressources/photos ; doc test-data + seed ; smoke curl inscription AM. Made-with: Cursor --- backend/scripts/test-register-am.sh | 32 +- database/seed/03_seed_test_data.sql | 1 + docs/test-data/README.md | 40 ++- docs/test-data/utilisateurs-test.html | 8 +- frontend/lib/models/am_registration_data.dart | 11 +- .../auth/am_register_step2_screen.dart | 2 + .../auth/am_register_step4_screen.dart | 1 + frontend/lib/services/auth_service.dart | 23 +- frontend/lib/utils/nir_utils.dart | 22 +- frontend/lib/widgets/child_card_widget.dart | 123 +------ frontend/lib/widgets/nir_text_field.dart | 2 +- .../professional_info_form_screen.dart | 340 ++++++++++++------ .../lib/widgets/registration_photo_slot.dart | 169 +++++++++ tests/scripts/register-am-dubois-test.mjs | 127 +++++++ tests/scripts/register-am-mansouri-test.mjs | 126 +++++++ .../register-parent-durand-rousseau-test.mjs | 8 +- .../scripts/register-parent-lecomte-test.mjs | 4 +- tests/scripts/register-parent-martin-test.mjs | 8 +- 18 files changed, 763 insertions(+), 284 deletions(-) create mode 100644 frontend/lib/widgets/registration_photo_slot.dart create mode 100644 tests/scripts/register-am-dubois-test.mjs create mode 100644 tests/scripts/register-am-mansouri-test.mjs diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh index 0ae987e..5a5b01b 100755 --- a/backend/scripts/test-register-am.sh +++ b/backend/scripts/test-register-am.sh @@ -1,27 +1,31 @@ #!/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, "acceptation_cgu": true, "acceptation_privacy": true }' 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..54e2f0c 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -24,6 +24,8 @@ 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 // Step 3: Presentation & CGU @@ -68,6 +70,7 @@ class AmRegistrationData extends ChangeNotifier { // String? placeOfBirth, // Remplacé String? nir, String? agrementNumber, + DateTime? agreementDate, int? capacity, }) { this.photoPath = photoPath; @@ -80,6 +83,7 @@ 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; notifyListeners(); } @@ -118,7 +122,10 @@ class AmRegistrationData extends ChangeNotifier { birthCountry.isNotEmpty && nir.isNotEmpty && // Basic check, could add validation agrementNumber.isNotEmpty && - capacity != null && capacity! > 0; + agreementDate != null && + capacity != null && + capacity! >= 1 && + capacity! <= 10; bool get isStep3Complete => // presentationText is optional as per CDC (message au gestionnaire) @@ -135,7 +142,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, ' 'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, ' 'presentationText: $presentationText, cguAccepted: $cguAccepted)'; } diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index aba12e4..1eea9ff 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -23,6 +23,7 @@ class AmRegisterStep2Screen extends StatelessWidget { birthCountry: registrationData.birthCountry, nir: registrationData.nir, agrementNumber: registrationData.agrementNumber, + agreementDate: registrationData.agreementDate, capacity: registrationData.capacity, ); @@ -43,6 +44,7 @@ class AmRegisterStep2Screen extends StatelessWidget { birthCountry: data.birthCountry, nir: data.nir, agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, capacity: data.capacity, ); 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..6e2a350 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -204,6 +204,7 @@ class _AmRegisterStep4ScreenState extends State { birthCountry: data.birthCountry, nir: data.nir, agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, capacity: data.capacity, photoConsent: data.photoConsent, ), diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 8a1fc64..c2edf66 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,15 @@ 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.'); + } 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 +181,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'; } @@ -191,6 +208,8 @@ class AuthService { 'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null, '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, 'biographie': data.presentationText.isNotEmpty ? data.presentationText : null, 'acceptation_cgu': 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/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 7222811..90d9e29 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget { super.key, required this.controller, this.labelText = 'N° Sécurité Sociale (NIR)', - this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)', + this.hintText = '15 caractères dont clé valide (dépt 2A ou 2B si Corse)', this.validator, this.fieldWidth = double.infinity, this.fieldHeight = 53.0, diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 1f7f99c..72ce73c 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -10,12 +10,14 @@ import 'dart:io'; 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,6 +31,8 @@ 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; ProfessionalInfoData({ @@ -42,6 +46,7 @@ class ProfessionalInfoData { this.birthCountry = '', this.nir = '', this.agrementNumber = '', + this.agreementDate, this.capacity, }); } @@ -87,9 +92,14 @@ class _ProfessionalInfoFormScreenState extends State final _birthCountryController = TextEditingController(); final _nirController = TextEditingController(); final _agrementController = TextEditingController(); + final _agreementDateController = TextEditingController(); final _capacityController = TextEditingController(); + + FocusNode? _birthCityFocus; + FocusNode? _birthCountryFocus; DateTime? _selectedDate; + DateTime? _selectedAgreementDate; String? _photoPathFramework; File? _photoFile; Uint8List? _photoBytes; @@ -118,6 +128,10 @@ 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() ?? ''; _photoPathFramework = data.photoPath; _photoFile = data.photoFile; @@ -125,15 +139,46 @@ class _ProfessionalInfoFormScreenState extends State _photoFilename = data.photoFilename; _photoConsent = data.photoConsent; } + + if (widget.mode == DisplayMode.editable) { + _birthCityFocus = FocusNode(); + _birthCountryFocus = 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(); _dateOfBirthController.dispose(); _birthCityController.dispose(); _birthCountryController.dispose(); _nirController.dispose(); _agrementController.dispose(); + _agreementDateController.dispose(); _capacityController.dispose(); super.dispose(); } @@ -154,6 +199,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,7 +251,19 @@ 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) { ScaffoldMessenger.of(context).showSnackBar( @@ -210,6 +283,7 @@ class _ProfessionalInfoFormScreenState extends State birthCountry: _birthCountryController.text, nir: normalizeNir(_nirController.text), agrementNumber: _agrementController.text, + agreementDate: _selectedAgreementDate, capacity: int.tryParse(_capacityController.text), ); @@ -491,35 +565,24 @@ 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), AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', value: _photoConsent, - onChanged: (v) {}, // Readonly + onChanged: (_) {}, // Readonly checkboxSize: 22.0, fontSize: 14.0, ), @@ -534,32 +597,49 @@ class _ProfessionalInfoFormScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Ligne 1 : Ville + Pays + // Ligne 1 : Date de naissance + Ville Row( children: [ - Expanded(child: _buildReadonlyField('Ville de naissance', _birthCityController.text)), + Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), const SizedBox(width: 16), - Expanded(child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)), + Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)), ], ), const SizedBox(height: 12), - // Ligne 2 : Date + NIR (NIR prend plus de place si possible ou 50/50) + // Ligne 2 : Pays + NIR Row( children: [ - Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), + 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), - // 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: 12), + Row( + children: [ + Expanded( + child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text), + ), ], ), ], @@ -633,22 +713,6 @@ class _ProfessionalInfoFormScreenState extends State 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', @@ -659,6 +723,24 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: Icons.calendar_today, validator: (v) => _selectedDate == null ? 'Date requise' : null, ), + SizedBox(height: verticalSpacing), + _buildField( + config: config, + label: 'Ville de naissance', + controller: _birthCityController, + hint: 'Ex. Ajaccio, Paris, Casablanca…', + validator: (v) => v!.isEmpty ? 'Ville requise' : null, + focusNode: _birthCityFocus, + ), + SizedBox(height: verticalSpacing), + _buildField( + config: config, + label: 'Pays de naissance', + controller: _birthCountryController, + hint: 'Ex. France, Maroc…', + validator: (v) => v!.isEmpty ? 'Pays requis' : null, + focusNode: _birthCountryFocus, + ), ], ), ), @@ -674,6 +756,7 @@ class _ProfessionalInfoFormScreenState extends State ), SizedBox(height: verticalSpacing), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _buildField( @@ -688,20 +771,33 @@ class _ProfessionalInfoFormScreenState extends State Expanded( child: _buildField( config: config, - label: 'Capacité d\'accueil', - controller: _capacityController, - hint: 'Ex: 3', - 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'; - return null; - }, + 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, ), ), ], ), + SizedBox(height: verticalSpacing), + _buildField( + config: config, + label: 'Capacité d\'accueil', + controller: _capacityController, + hint: 'De 1 à 10 enfants', + keyboardType: TextInputType.number, + validator: (v) { + if (v == null || v.isEmpty) return 'Capacité requise'; + final n = int.tryParse(v); + if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10'; + if (n > 10) return 'Maximum 10 (plafond agrément)'; + return null; + }, + ), ], ); } @@ -715,24 +811,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', @@ -745,6 +823,26 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), + _buildField( + config: config, + label: 'Ville de naissance', + controller: _birthCityController, + hint: 'Ex. Ajaccio, Paris, Casablanca…', + validator: (v) => v!.isEmpty ? 'Ville requise' : null, + focusNode: _birthCityFocus, + ), + const SizedBox(height: 12), + + _buildField( + config: config, + label: 'Pays de naissance', + controller: _birthCountryController, + hint: 'Ex. France, Maroc…', + validator: (v) => v!.isEmpty ? 'Pays requis' : null, + focusNode: _birthCountryFocus, + ), + const SizedBox(height: 12), + NirTextField( controller: _nirController, fieldWidth: double.infinity, @@ -754,25 +852,46 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), - _buildField( - config: config, - label: 'N° d\'agrément', - controller: _agrementController, - hint: 'Votre numéro d\'agrément', - validator: (v) => v!.isEmpty ? 'Agrément requis' : null, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildField( + config: config, + label: 'N° d\'agrément', + controller: _agrementController, + hint: 'N° agrément', + validator: (v) => v!.isEmpty ? 'Agrément requis' : null, + ), + ), + const SizedBox(width: 12), + 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, + ), + ), + ], ), const SizedBox(height: 12), - _buildField( config: config, label: 'Capacité d\'accueil', controller: _capacityController, - hint: 'Ex: 3', + hint: 'De 1 à 10 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 10'; + if (n > 10) return 'Maximum 10 (plafond agrément)'; return null; }, ), @@ -780,52 +899,33 @@ class _ProfessionalInfoFormScreenState extends State ); } - /// 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; 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, - ), - ), + 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, ), const SizedBox(height: 10), 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 +943,7 @@ class _ProfessionalInfoFormScreenState extends State IconData? suffixIcon, String? Function(String?)? validator, List? inputFormatters, + FocusNode? focusNode, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -853,6 +954,7 @@ class _ProfessionalInfoFormScreenState extends State } else { return CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, fieldWidth: double.infinity, 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..6133645 --- /dev/null +++ b/tests/scripts/register-am-dubois-test.mjs @@ -0,0 +1,127 @@ +/** + * 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, + 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..a359a79 --- /dev/null +++ b/tests/scripts/register-am-mansouri-test.mjs @@ -0,0 +1,126 @@ +/** + * 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, + 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..e778b62 100644 --- a/tests/scripts/register-parent-lecomte-test.mjs +++ b/tests/scripts/register-parent-lecomte-test.mjs @@ -13,7 +13,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 toDataUri(filePath) { const buf = fs.readFileSync(filePath); @@ -40,7 +40,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..5a63126 100644 --- a/tests/scripts/register-parent-martin-test.mjs +++ b/tests/scripts/register-parent-martin-test.mjs @@ -14,7 +14,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 toDataUri(filePath) { const buf = fs.readFileSync(filePath); @@ -46,7 +46,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 +55,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 +64,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, }, From b4b44cb1b9ed676fa1d8d2ea6c7cbeea0b28f214 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 13:13:27 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(inscription-am):=20places=20disponible?= =?UTF-8?q?s,=20UI=20=C3=A9tape=202,=20plafond=20capacit=C3=A9=204=20c?= =?UTF-8?q?=C3=B4t=C3=A9=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/register/am : places_disponibles, enregistrement place_disponible, contrôle ≤ capacité - Formulaire pro AM : places sur la ligne capacité, mobile un champ par ligne, carte desktop plus compacte (espacements, polices, labelFieldSpacing) - Capacité et places validées jusqu’à 4 dans l’app (kAmCapaciteAccueilMax) ; hint NIR « 13 chiffres + clé » - Scripts d’inscription AM (Node + smoke shell) : places_disponibles Made-with: Cursor --- backend/scripts/test-register-am.sh | 1 + backend/src/routes/auth/auth.service.ts | 6 + .../auth/dto/register-am-complet.dto.ts | 11 ++ frontend/lib/models/am_registration_data.dart | 14 +- .../auth/am_register_step2_screen.dart | 2 + .../auth/am_register_step4_screen.dart | 1 + frontend/lib/services/auth_service.dart | 4 + .../lib/widgets/custom_app_text_field.dart | 5 +- frontend/lib/widgets/nir_text_field.dart | 5 +- .../professional_info_form_screen.dart | 180 +++++++++++------- tests/scripts/register-am-dubois-test.mjs | 1 + tests/scripts/register-am-mansouri-test.mjs | 1 + 12 files changed, 163 insertions(+), 68 deletions(-) diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh index 5a5b01b..db139a6 100755 --- a/backend/scripts/test-register-am.sh +++ b/backend/scripts/test-register-am.sh @@ -26,6 +26,7 @@ curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ "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/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 54e2f0c..6bc88e6 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 = ''; @@ -27,6 +30,8 @@ class AmRegistrationData extends ChangeNotifier { /// 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 = ''; @@ -72,6 +77,7 @@ class AmRegistrationData extends ChangeNotifier { String? agrementNumber, DateTime? agreementDate, int? capacity, + int? placesAvailable, }) { this.photoPath = photoPath; this.photoBytes = photoBytes; @@ -85,6 +91,7 @@ class AmRegistrationData extends ChangeNotifier { this.agrementNumber = agrementNumber ?? this.agrementNumber; this.agreementDate = agreementDate ?? this.agreementDate; this.capacity = capacity ?? this.capacity; + this.placesAvailable = placesAvailable ?? this.placesAvailable; notifyListeners(); } @@ -125,7 +132,10 @@ class AmRegistrationData extends ChangeNotifier { agreementDate != null && capacity != null && capacity! >= 1 && - capacity! <= 10; + capacity! <= kAmCapaciteAccueilMax && + placesAvailable != null && + placesAvailable! >= 0 && + placesAvailable! <= capacity!; bool get isStep3Complete => // presentationText is optional as per CDC (message au gestionnaire) @@ -142,7 +152,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, agreementDate: $agreementDate, 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_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 1eea9ff..2f98b0e 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -25,6 +25,7 @@ class AmRegisterStep2Screen extends StatelessWidget { agrementNumber: registrationData.agrementNumber, agreementDate: registrationData.agreementDate, capacity: registrationData.capacity, + placesAvailable: registrationData.placesAvailable, ); return ProfessionalInfoFormScreen( @@ -46,6 +47,7 @@ class AmRegisterStep2Screen extends StatelessWidget { 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 6e2a350..b49cd8c 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -206,6 +206,7 @@ class _AmRegisterStep4ScreenState extends State { 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 c2edf66..99d52aa 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -165,6 +165,9 @@ class AuthService { 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.'); + } String? photoBase64; String? photoFilename; @@ -211,6 +214,7 @@ class AuthService { '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/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index dae6364..72d5b84 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -27,6 +27,8 @@ 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; @@ -56,6 +58,7 @@ class CustomAppTextField extends StatefulWidget { this.suffixIcon, this.labelFontSize = 18.0, this.inputFontSize = 18.0, + this.labelFieldSpacing = 6.0, this.autofillHints, this.textInputAction, this.onFieldSubmitted, @@ -105,7 +108,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. diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 90d9e29..0e0f951 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -18,12 +18,13 @@ class NirTextField extends StatelessWidget { final bool enabled; final bool readOnly; final CustomAppTextFieldStyle style; + final double labelFieldSpacing; const NirTextField({ super.key, required this.controller, this.labelText = 'N° Sécurité Sociale (NIR)', - this.hintText = '15 caractères dont clé valide (dépt 2A ou 2B si Corse)', + this.hintText = '13 chiffres + clé', this.validator, this.fieldWidth = double.infinity, this.fieldHeight = 53.0, @@ -32,6 +33,7 @@ class NirTextField extends StatelessWidget { this.enabled = true, this.readOnly = false, this.style = CustomAppTextFieldStyle.beige, + this.labelFieldSpacing = 6.0, }); @override @@ -50,6 +52,7 @@ class NirTextField extends StatelessWidget { enabled: enabled, readOnly: readOnly, style: style, + labelFieldSpacing: labelFieldSpacing, ); } } diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 72ce73c..811f746 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -7,6 +7,7 @@ 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'; @@ -34,6 +35,7 @@ class ProfessionalInfoData { /// Date d'obtention de l'agrément (obligatoire, API `date_agrement`). final DateTime? agreementDate; final int? capacity; + final int? placesAvailable; ProfessionalInfoData({ this.photoPath, @@ -48,6 +50,7 @@ class ProfessionalInfoData { this.agrementNumber = '', this.agreementDate, this.capacity, + this.placesAvailable, }); } @@ -94,6 +97,7 @@ class _ProfessionalInfoFormScreenState extends State final _agrementController = TextEditingController(); final _agreementDateController = TextEditingController(); final _capacityController = TextEditingController(); + final _placesAvailableController = TextEditingController(); FocusNode? _birthCityFocus; FocusNode? _birthCountryFocus; @@ -133,6 +137,7 @@ class _ProfessionalInfoFormScreenState extends State ? 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; @@ -180,9 +185,20 @@ class _ProfessionalInfoFormScreenState extends State _agrementController.dispose(); _agreementDateController.dispose(); _capacityController.dispose(); + _placesAvailableController.dispose(); super.dispose(); } + 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, @@ -285,6 +301,7 @@ class _ProfessionalInfoFormScreenState extends State agrementNumber: _agrementController.text, agreementDate: _selectedAgreementDate, capacity: int.tryParse(_capacityController.text), + placesAvailable: int.tryParse(_placesAvailableController.text), ); widget.onSubmit(data); @@ -308,7 +325,7 @@ class _ProfessionalInfoFormScreenState extends State ), Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 40.0), + padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -329,7 +346,7 @@ class _ProfessionalInfoFormScreenState extends State ), textAlign: TextAlign.center, ), - SizedBox(height: config.isMobile ? 16 : 30), + SizedBox(height: config.isMobile ? 16 : 20), _buildCard(context, config, screenSize), // Boutons mobile sous la carte @@ -400,7 +417,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( @@ -428,7 +445,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) @@ -550,7 +567,7 @@ class _ProfessionalInfoFormScreenState extends State ), ], ), - const SizedBox(height: 18), + const SizedBox(height: 10), // Contenu Expanded( @@ -578,7 +595,7 @@ class _ProfessionalInfoFormScreenState extends State }, ), ), - const SizedBox(height: 10), + const SizedBox(height: 5), AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', value: _photoConsent, @@ -589,7 +606,7 @@ class _ProfessionalInfoFormScreenState extends State ], ), ), - const SizedBox(width: 32), + const SizedBox(width: 24), // CHAMPS (2/3) - Layout optimisé compact Expanded( @@ -605,7 +622,7 @@ class _ProfessionalInfoFormScreenState extends State Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)), ], ), - const SizedBox(height: 12), + const SizedBox(height: 5), // Ligne 2 : Pays + NIR Row( @@ -615,7 +632,7 @@ class _ProfessionalInfoFormScreenState extends State Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))), ], ), - const SizedBox(height: 12), + const SizedBox(height: 5), // Ligne 3 : N° agrément + Date d'obtention Row( @@ -634,12 +651,19 @@ class _ProfessionalInfoFormScreenState extends State ), ], ), - const SizedBox(height: 12), + 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, + ), + ), ], ), ], @@ -668,10 +692,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 @@ -685,7 +709,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, ), ), @@ -695,7 +719,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, @@ -708,7 +732,7 @@ class _ProfessionalInfoFormScreenState extends State width: 300, child: _buildPhotoSection(context, config), ), - const SizedBox(width: 30), + const SizedBox(width: 22), // Champs à droite Expanded( child: Column( @@ -751,8 +775,9 @@ class _ProfessionalInfoFormScreenState extends State controller: _nirController, 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( @@ -767,7 +792,7 @@ class _ProfessionalInfoFormScreenState extends State validator: (v) => v!.isEmpty ? 'Agrément requis' : null, ), ), - const SizedBox(width: 20), + const SizedBox(width: 14), Expanded( child: _buildField( config: config, @@ -784,19 +809,41 @@ class _ProfessionalInfoFormScreenState extends State ], ), SizedBox(height: verticalSpacing), - _buildField( - config: config, - label: 'Capacité d\'accueil', - controller: _capacityController, - hint: 'De 1 à 10 enfants', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'Capacité requise'; - final n = int.tryParse(v); - if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10'; - if (n > 10) return 'Maximum 10 (plafond agrément)'; - return null; - }, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildField( + config: config, + label: 'Capacité d\'accueil', + controller: _capacityController, + 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 < 1) { + return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax'; + } + if (n > kAmCapaciteAccueilMax) { + return 'Maximum $kAmCapaciteAccueilMax'; + } + return null; + }, + ), + ), + 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, + ), + ), + ], ), ], ); @@ -852,49 +899,53 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _buildField( - config: config, - label: 'N° d\'agrément', - controller: _agrementController, - hint: 'N° agrément', - validator: (v) => v!.isEmpty ? 'Agrément requis' : null, - ), - ), - const SizedBox(width: 12), - 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, - ), - ), - ], + _buildField( + config: config, + label: 'N° d\'agrément', + controller: _agrementController, + hint: 'Votre numéro d\'agrément', + validator: (v) => v!.isEmpty ? 'Agrément requis' : null, + ), + 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, ), const SizedBox(height: 12), _buildField( config: config, label: 'Capacité d\'accueil', controller: _capacityController, - hint: 'De 1 à 10 enfants', + 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 < 1) return 'Entrez un nombre entre 1 et 10'; - if (n > 10) return 'Maximum 10 (plafond agrément)'; + if (n == null || n < 1) { + return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax'; + } + if (n > kAmCapaciteAccueilMax) { + return 'Maximum $kAmCapaciteAccueilMax'; + } return null; }, ), + const SizedBox(height: 12), + _buildField( + config: config, + label: 'Places disponibles', + controller: _placesAvailableController, + hint: 'Entre 0 et la capacité', + keyboardType: TextInputType.number, + validator: _validatePlacesAvailable, + ), ], ); } @@ -918,7 +969,7 @@ class _ProfessionalInfoFormScreenState extends State baseShadowColor: Colors.green.shade300, isMobile: config.isMobile, ), - const SizedBox(height: 10), + SizedBox(height: config.isMobile ? 10.0 : 6.0), AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', value: _photoConsent, @@ -959,8 +1010,9 @@ class _ProfessionalInfoFormScreenState extends State 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, diff --git a/tests/scripts/register-am-dubois-test.mjs b/tests/scripts/register-am-dubois-test.mjs index 6133645..9cdcd6c 100644 --- a/tests/scripts/register-am-dubois-test.mjs +++ b/tests/scripts/register-am-dubois-test.mjs @@ -61,6 +61,7 @@ try { 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.', diff --git a/tests/scripts/register-am-mansouri-test.mjs b/tests/scripts/register-am-mansouri-test.mjs index a359a79..1e52d62 100644 --- a/tests/scripts/register-am-mansouri-test.mjs +++ b/tests/scripts/register-am-mansouri-test.mjs @@ -60,6 +60,7 @@ try { 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.', From d550e57cb8e30adab378e2dc8a2c17988971ea01 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 13:32:25 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(inscription-am):=20=C3=A9tape=201/2=20o?= =?UTF-8?q?bligatoires=20align=C3=A9es=20API=20(min=202=20car.,=20photo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM) - Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict - registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide - Étape 4 : blocage envoi si isRegistrationComplete faux Made-with: Cursor --- frontend/lib/models/am_registration_data.dart | 19 ++++++----- .../auth/am_register_step1_screen.dart | 1 + .../auth/am_register_step4_screen.dart | 13 +++++++ frontend/lib/services/auth_service.dart | 19 ++++++++--- .../widgets/personal_info_form_screen.dart | 25 ++++++++++++++ .../professional_info_form_screen.dart | 34 +++++++++++++++---- 6 files changed, 91 insertions(+), 20 deletions(-) diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 6bc88e6..44604df 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -106,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) @@ -123,11 +123,12 @@ 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 && agreementDate != null && capacity != null && 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_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index b49cd8c..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); diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 99d52aa..77f9587 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -168,6 +168,13 @@ class AuthService { 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; @@ -193,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, @@ -201,14 +212,14 @@ 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': 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 811f746..5a111cb 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -189,6 +189,20 @@ class _ProfessionalInfoFormScreenState extends State 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); @@ -281,7 +295,13 @@ class _ProfessionalInfoFormScreenState extends State _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.')), ); @@ -295,8 +315,8 @@ 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, @@ -753,7 +773,7 @@ class _ProfessionalInfoFormScreenState extends State label: 'Ville de naissance', controller: _birthCityController, hint: 'Ex. Ajaccio, Paris, Casablanca…', - validator: (v) => v!.isEmpty ? 'Ville requise' : null, + validator: _validateBirthCity, focusNode: _birthCityFocus, ), SizedBox(height: verticalSpacing), @@ -762,7 +782,7 @@ class _ProfessionalInfoFormScreenState extends State label: 'Pays de naissance', controller: _birthCountryController, hint: 'Ex. France, Maroc…', - validator: (v) => v!.isEmpty ? 'Pays requis' : null, + validator: _validateBirthCountry, focusNode: _birthCountryFocus, ), ], @@ -875,7 +895,7 @@ class _ProfessionalInfoFormScreenState extends State label: 'Ville de naissance', controller: _birthCityController, hint: 'Ex. Ajaccio, Paris, Casablanca…', - validator: (v) => v!.isEmpty ? 'Ville requise' : null, + validator: _validateBirthCity, focusNode: _birthCityFocus, ), const SizedBox(height: 12), @@ -885,7 +905,7 @@ class _ProfessionalInfoFormScreenState extends State label: 'Pays de naissance', controller: _birthCountryController, hint: 'Ex. France, Maroc…', - validator: (v) => v!.isEmpty ? 'Pays requis' : null, + validator: _validateBirthCountry, focusNode: _birthCountryFocus, ), const SizedBox(height: 12), From a9ce4a6756199e91397bc431c66770b986812f56 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 14:09:21 +0200 Subject: [PATCH 6/6] fix(am): ordre Tab formulaire pro et chevrons accessibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ordre de focus explicite (NumericFocusOrder) pour l’inscription AM étape 2 : chaîne des champs jusqu’aux chevrons, Pays → NIR, places → Suivant (10) puis Précédent (11). - FocusTraversalGroup avec OrderedTraversalPolicy sur le Scaffold pour respecter cet ordre (desktop / web). - Champ pays : TextInputAction.next, onFieldSubmitted et onEditingComplete vers le NIR ; CustomAppTextField expose onEditingComplete. - NirTextField : focusNode et focusTraversalOrder optionnels. Made-with: Cursor --- .../lib/widgets/custom_app_text_field.dart | 4 + frontend/lib/widgets/nir_text_field.dart | 16 +- .../professional_info_form_screen.dart | 212 ++++++++++++------ 3 files changed, 160 insertions(+), 72 deletions(-) diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 72d5b84..e8e01a9 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -33,6 +33,8 @@ class CustomAppTextField extends StatefulWidget { 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; @@ -62,6 +64,7 @@ class CustomAppTextField extends StatefulWidget { this.autofillHints, this.textInputAction, this.onFieldSubmitted, + this.onEditingComplete, this.inputFormatters, this.autocorrect = true, this.enableSuggestions = true, @@ -144,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 0e0f951..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; @@ -23,6 +26,8 @@ class NirTextField extends StatelessWidget { const NirTextField({ super.key, required this.controller, + this.focusNode, + this.focusTraversalOrder, this.labelText = 'N° Sécurité Sociale (NIR)', this.hintText = '13 chiffres + clé', this.validator, @@ -38,8 +43,9 @@ class NirTextField extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomAppTextField( + final field = CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: labelText, hintText: hintText, fieldWidth: fieldWidth, @@ -54,5 +60,13 @@ class NirTextField extends StatelessWidget { 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/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 5a111cb..6225cd1 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -89,7 +89,20 @@ 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(); @@ -101,7 +114,8 @@ class _ProfessionalInfoFormScreenState extends State FocusNode? _birthCityFocus; FocusNode? _birthCountryFocus; - + FocusNode? _nirFocus; + DateTime? _selectedDate; DateTime? _selectedAgreementDate; String? _photoPathFramework; @@ -148,6 +162,7 @@ class _ProfessionalInfoFormScreenState extends State if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); _birthCountryFocus = FocusNode(); + _nirFocus = FocusNode(); _birthCityFocus!.addListener(_onBirthCityFocusChange); _birthCountryFocus!.addListener(_onBirthCountryFocusChange); } @@ -178,6 +193,7 @@ class _ProfessionalInfoFormScreenState extends State _birthCountryFocus?.removeListener(_onBirthCountryFocusChange); _birthCityFocus?.dispose(); _birthCountryFocus?.dispose(); + _nirFocus?.dispose(); _dateOfBirthController.dispose(); _birthCityController.dispose(); _birthCountryController.dispose(); @@ -337,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: 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, + 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 : 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), + 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', + ), ), ), ], ], + ), ), ); } @@ -766,6 +789,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: () => _selectDate(context), suffixIcon: Icons.calendar_today, validator: (v) => _selectedDate == null ? 'Date requise' : null, + focusTraversalOrder: _kTabDateBirth, ), SizedBox(height: verticalSpacing), _buildField( @@ -775,6 +799,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. Ajaccio, Paris, Casablanca…', validator: _validateBirthCity, focusNode: _birthCityFocus, + focusTraversalOrder: _kTabBirthCity, ), SizedBox(height: verticalSpacing), _buildField( @@ -784,6 +809,10 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. France, Maroc…', validator: _validateBirthCountry, focusNode: _birthCountryFocus, + focusTraversalOrder: _kTabBirthCountry, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _nirFocus?.requestFocus(), + onEditingComplete: () => _nirFocus?.requestFocus(), ), ], ), @@ -793,6 +822,8 @@ 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 : 20.0, @@ -810,6 +841,7 @@ 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: 14), @@ -824,6 +856,7 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: Icons.calendar_today, validator: (_) => _selectedAgreementDate == null ? 'Date d\'obtention requise' : null, + focusTraversalOrder: _kTabAgreementDate, ), ), ], @@ -850,6 +883,7 @@ class _ProfessionalInfoFormScreenState extends State } return null; }, + focusTraversalOrder: _kTabCapacity, ), ), const SizedBox(width: 14), @@ -861,6 +895,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Entre 0 et la capacité', keyboardType: TextInputType.number, validator: _validatePlacesAvailable, + focusTraversalOrder: _kTabPlaces, ), ), ], @@ -887,6 +922,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: () => _selectDate(context), suffixIcon: Icons.calendar_today, validator: (v) => _selectedDate == null ? 'Date requise' : null, + focusTraversalOrder: _kTabDateBirth, ), const SizedBox(height: 12), @@ -897,6 +933,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. Ajaccio, Paris, Casablanca…', validator: _validateBirthCity, focusNode: _birthCityFocus, + focusTraversalOrder: _kTabBirthCity, ), const SizedBox(height: 12), @@ -907,11 +944,17 @@ class _ProfessionalInfoFormScreenState extends State 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, @@ -925,6 +968,7 @@ 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( @@ -937,6 +981,7 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: Icons.calendar_today, validator: (_) => _selectedAgreementDate == null ? 'Date d\'obtention requise' : null, + focusTraversalOrder: _kTabAgreementDate, ), const SizedBox(height: 12), _buildField( @@ -956,6 +1001,7 @@ class _ProfessionalInfoFormScreenState extends State } return null; }, + focusTraversalOrder: _kTabCapacity, ), const SizedBox(height: 12), _buildField( @@ -965,6 +1011,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Entre 0 et la capacité', keyboardType: TextInputType.number, validator: _validatePlacesAvailable, + focusTraversalOrder: _kTabPlaces, ), ], ); @@ -975,20 +1022,28 @@ class _ProfessionalInfoFormScreenState extends State 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: [ - 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, - ), + if (config.isReadonly) + slot + else + FocusTraversalOrder( + order: const NumericFocusOrder(_kTabPhotoPick), + child: slot, + ), SizedBox(height: config.isMobile ? 10.0 : 6.0), AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', @@ -1015,6 +1070,10 @@ class _ProfessionalInfoFormScreenState extends State String? Function(String?)? validator, List? inputFormatters, FocusNode? focusNode, + double? focusTraversalOrder, + TextInputAction? textInputAction, + ValueChanged? onFieldSubmitted, + VoidCallback? onEditingComplete, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -1023,7 +1082,7 @@ class _ProfessionalInfoFormScreenState extends State value: controller.text, ); } else { - return CustomAppTextField( + final field = CustomAppTextField( controller: controller, focusNode: focusNode, labelText: label, @@ -1039,7 +1098,18 @@ class _ProfessionalInfoFormScreenState extends State 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; } }