From 01c1be8f1673cd65802921795f2d7067c2e25c0a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Apr 2026 21:41:19 +0200 Subject: [PATCH] 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), ), ], );