feat(am): inscription photo (picker, bytes web, photo_filename) (#120)

- 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
This commit is contained in:
MARTIN Julien 2026-04-12 21:41:19 +02:00
parent d69768806c
commit 62b2466cce
6 changed files with 147 additions and 60 deletions

View File

@ -101,6 +101,7 @@
| 116 | Rattachement parent frontend | Ouvert | | 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | Ouvert | | 117 | Évolution du cahier des charges | Ouvert |
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé | | 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* *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*

View File

@ -14,7 +14,9 @@ class AmRegistrationData extends ChangeNotifier {
// bool photoConsent = false; // Déplacé ou géré à l'étape 2 // bool photoConsent = false; // Déplacé ou géré à l'étape 2
// Step 2: Professional Info // Step 2: Professional Info
String? photoPath; // Ajouté pour l'étape 2 String? photoPath; // Chemin fichier local (hors web) si pas doctets en mémoire
Uint8List? photoBytes; // Galerie / web (ImagePicker)
String? photoFilename; // Nom pour lAPI (ex. image.jpg)
bool photoConsent = false; // Ajouté pour l'étape 2 bool photoConsent = false; // Ajouté pour l'étape 2
DateTime? dateOfBirth; DateTime? dateOfBirth;
String birthCity = ''; // Nouveau String birthCity = ''; // Nouveau
@ -57,6 +59,8 @@ class AmRegistrationData extends ChangeNotifier {
void updateProfessionalInfo({ void updateProfessionalInfo({
String? photoPath, String? photoPath,
Uint8List? photoBytes,
String? photoFilename,
bool? photoConsent, bool? photoConsent,
DateTime? dateOfBirth, DateTime? dateOfBirth,
String? birthCity, // Nouveau String? birthCity, // Nouveau
@ -66,10 +70,9 @@ class AmRegistrationData extends ChangeNotifier {
String? agrementNumber, String? agrementNumber,
int? capacity, int? capacity,
}) { }) {
// Allow setting photoPath to null explicitly this.photoPath = photoPath;
if (photoPath != null || this.photoPath != null) { this.photoBytes = photoBytes;
this.photoPath = photoPath; this.photoFilename = photoFilename;
}
this.photoConsent = photoConsent ?? this.photoConsent; this.photoConsent = photoConsent ?? this.photoConsent;
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth; this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
this.birthCity = birthCity ?? this.birthCity; // Nouveau this.birthCity = birthCity ?? this.birthCity; // Nouveau
@ -101,12 +104,15 @@ class AmRegistrationData extends ChangeNotifier {
email.isNotEmpty; email.isNotEmpty;
// password n'est pas requis à l'inscription (défini après validation par lien email) // 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 => bool get isStep2Complete =>
// photoConsent is mandatory if a photo is system-required, otherwise optional. (_hasUserPhoto ? photoConsent == true : true) &&
// 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
dateOfBirth != null && dateOfBirth != null &&
birthCity.isNotEmpty && birthCity.isNotEmpty &&
birthCountry.isNotEmpty && birthCountry.isNotEmpty &&

View File

@ -6,27 +6,17 @@ import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
import '../../widgets/professional_info_form_screen.dart'; import '../../widgets/professional_info_form_screen.dart';
class AmRegisterStep2Screen extends StatefulWidget { class AmRegisterStep2Screen extends StatelessWidget {
const AmRegisterStep2Screen({super.key}); const AmRegisterStep2Screen({super.key});
@override
State<AmRegisterStep2Screen> createState() => _AmRegisterStep2ScreenState();
}
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
String? _photoPathFramework;
Future<void> _pickPhoto() async {
// TODO: brancher ImagePicker ; ne pas préremplir de chemin factice
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false); final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
// Préparer les données initiales final initialData = ProfessionalInfoData(
ProfessionalInfoData initialData = ProfessionalInfoData(
photoPath: registrationData.photoPath, photoPath: registrationData.photoPath,
photoBytes: registrationData.photoBytes,
photoFilename: registrationData.photoFilename,
photoConsent: registrationData.photoConsent, photoConsent: registrationData.photoConsent,
dateOfBirth: registrationData.dateOfBirth, dateOfBirth: registrationData.dateOfBirth,
birthCity: registrationData.birthCity, birthCity: registrationData.birthCity,
@ -42,10 +32,11 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
cardColor: CardColorHorizontal.green, cardColor: CardColorHorizontal.green,
initialData: initialData, initialData: initialData,
previousRoute: '/am-register-step1', previousRoute: '/am-register-step1',
onPickPhoto: _pickPhoto,
onSubmit: (data) { onSubmit: (data) {
registrationData.updateProfessionalInfo( registrationData.updateProfessionalInfo(
photoPath: _photoPathFramework ?? data.photoPath, photoPath: data.photoPath,
photoBytes: data.photoBytes,
photoFilename: data.photoFilename,
photoConsent: data.photoConsent, photoConsent: data.photoConsent,
dateOfBirth: data.dateOfBirth, dateOfBirth: data.dateOfBirth,
birthCity: data.birthCity, birthCity: data.birthCity,
@ -58,4 +49,4 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
}, },
); );
} }
} }

View File

@ -196,8 +196,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
title: 'Informations professionnelles', title: 'Informations professionnelles',
cardColor: CardColorHorizontal.green, cardColor: CardColorHorizontal.green,
initialData: ProfessionalInfoData( initialData: ProfessionalInfoData(
// TODO: Gérer photoPath vs photoFile correctement photoPath: data.photoPath,
photoPath: null, // Pas d'accès facile au fichier ici, on verra photoBytes: data.photoBytes,
photoFilename: data.photoFilename,
dateOfBirth: data.dateOfBirth, dateOfBirth: data.dateOfBirth,
birthCity: data.birthCity, birthCity: data.birthCity,
birthCountry: data.birthCountry, birthCountry: data.birthCountry,

View File

@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
@ -13,6 +15,13 @@ import '../utils/nir_utils.dart';
class AuthService { class AuthService {
static const String _currentUserKey = 'current_user'; 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 /// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async { static Future<AppUser> 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. /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future<void> registerAM(AmRegistrationData data) async { static Future<void> registerAM(AmRegistrationData data) async {
String? photoBase64; String? photoBase64;
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) { String? photoFilename;
try {
final file = File(data.photoPath!); if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
if (await file.exists()) { photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}';
final bytes = await file.readAsBytes(); final fn = (data.photoFilename ?? '').trim();
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}'; photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
} } else if (data.photoPath != null &&
} catch (_) {} 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 = <String, dynamic>{
'email': data.email, 'email': data.email,
'prenom': data.firstName, 'prenom': data.firstName,
'nom': data.lastName, 'nom': data.lastName,
@ -161,6 +182,7 @@ class AuthService {
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null, 'ville': data.city.isNotEmpty ? data.city : null,
if (photoBase64 != null) 'photo_base64': photoBase64, if (photoBase64 != null) 'photo_base64': photoBase64,
if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent, 'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null 'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' ? '${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, 'acceptation_privacy': data.cguAccepted,
}; };
final response = await http.post( final String encodedBody;
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), try {
headers: ApiConfig.headers, encodedBody = jsonEncode(body);
body: jsonEncode(body), } catch (_) {
); throw Exception(
'Impossible de préparer lenvoi (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) { if (response.statusCode == 200 || response.statusCode == 201) {
return; return;

View File

@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:io'; import 'dart:io';
@ -19,6 +21,8 @@ import 'custom_navigation_button.dart';
class ProfessionalInfoData { class ProfessionalInfoData {
final String? photoPath; final String? photoPath;
final File? photoFile; final File? photoFile;
final Uint8List? photoBytes;
final String? photoFilename;
final bool photoConsent; final bool photoConsent;
final DateTime? dateOfBirth; final DateTime? dateOfBirth;
final String birthCity; final String birthCity;
@ -30,6 +34,8 @@ class ProfessionalInfoData {
ProfessionalInfoData({ ProfessionalInfoData({
this.photoPath, this.photoPath,
this.photoFile, this.photoFile,
this.photoBytes,
this.photoFilename,
this.photoConsent = false, this.photoConsent = false,
this.dateOfBirth, this.dateOfBirth,
this.birthCity = '', this.birthCity = '',
@ -86,8 +92,17 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
DateTime? _selectedDate; DateTime? _selectedDate;
String? _photoPathFramework; String? _photoPathFramework;
File? _photoFile; File? _photoFile;
Uint8List? _photoBytes;
String? _photoFilename;
bool _photoConsent = false; bool _photoConsent = false;
bool get _hasRealPhoto =>
(_photoBytes != null && _photoBytes!.isNotEmpty) ||
_photoFile != null ||
(_photoPathFramework != null &&
_photoPathFramework!.isNotEmpty &&
!_photoPathFramework!.startsWith('assets/'));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -106,6 +121,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_capacityController.text = data.capacity?.toString() ?? ''; _capacityController.text = data.capacity?.toString() ?? '';
_photoPathFramework = data.photoPath; _photoPathFramework = data.photoPath;
_photoFile = data.photoFile; _photoFile = data.photoFile;
_photoBytes = data.photoBytes;
_photoFilename = data.photoFilename;
_photoConsent = data.photoConsent; _photoConsent = data.photoConsent;
} }
} }
@ -140,18 +157,42 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Future<void> _pickPhoto() async { Future<void> _pickPhoto() async {
if (widget.onPickPhoto != null) { if (widget.onPickPhoto != null) {
await widget.onPickPhoto!(); await widget.onPickPhoto!();
} else { return;
// Comportement par défaut : utiliser un asset de test
setState(() {
_photoPathFramework = 'assets/images/icon_assmat.png';
_photoFile = null;
});
} }
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() { void _submitForm() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
if (_photoPathFramework != null && !_photoConsent) { if (_hasRealPhoto && !_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')), const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
); );
@ -159,8 +200,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} }
final data = ProfessionalInfoData( final data = ProfessionalInfoData(
photoPath: _photoPathFramework, photoPath: _photoBytes != null ? null : _photoPathFramework,
photoFile: _photoFile, photoFile: _photoFile,
photoBytes: _photoBytes,
photoFilename: _photoFilename,
photoConsent: _photoConsent, photoConsent: _photoConsent,
dateOfBirth: _selectedDate, dateOfBirth: _selectedDate,
birthCity: _birthCityController.text, birthCity: _birthCityController.text,
@ -169,7 +212,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
agrementNumber: _agrementController.text, agrementNumber: _agrementController.text,
capacity: int.tryParse(_capacityController.text), capacity: int.tryParse(_capacityController.text),
); );
widget.onSubmit(data); widget.onSubmit(data);
} }
} }
@ -461,11 +504,14 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: _photoFile != null child: _photoBytes != null && _photoBytes!.isNotEmpty
? Image.file(_photoFile!, fit: BoxFit.cover) ? Image.memory(_photoBytes!, fit: BoxFit.cover)
: (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/') : _photoFile != null
? Image.asset(_photoPathFramework!, fit: BoxFit.contain) ? Image.file(_photoFile!, fit: BoxFit.cover)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain)), : (_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<ProfessionalInfoFormScreen>
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
ImageProvider? currentImageProvider; ImageProvider? currentImageProvider;
if (_photoFile != null) { if (_photoBytes != null && _photoBytes!.isNotEmpty) {
currentImageProvider = MemoryImage(_photoBytes!);
} else if (_photoFile != null) {
currentImageProvider = FileImage(_photoFile!); currentImageProvider = FileImage(_photoFile!);
} else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) { } else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
currentImageProvider = AssetImage(_photoPathFramework!); currentImageProvider = AssetImage(_photoPathFramework!);
@ -777,7 +825,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
AppCustomCheckbox( AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.', label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent, value: _photoConsent,
onChanged: (val) => setState(() => _photoConsent = val ?? false), onChanged: (val) => setState(() => _photoConsent = val == true),
), ),
], ],
); );