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 cdae9b5f7c
commit 01c1be8f16
6 changed files with 147 additions and 60 deletions

View File

@ -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*

View File

@ -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 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
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 &&

View File

@ -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<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
Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(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<AmRegisterStep2Screen> {
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<AmRegisterStep2Screen> {
},
);
}
}
}

View File

@ -196,8 +196,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
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,

View File

@ -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<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.
static Future<void> 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 = <String, dynamic>{
'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 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) {
return;

View File

@ -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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
_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<ProfessionalInfoFormScreen>
Future<void> _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<ProfessionalInfoFormScreen>
}
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<ProfessionalInfoFormScreen>
agrementNumber: _agrementController.text,
capacity: int.tryParse(_capacityController.text),
);
widget.onSubmit(data);
}
}
@ -461,11 +504,14 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
onChanged: (val) => setState(() => _photoConsent = val ?? false),
onChanged: (val) => setState(() => _photoConsent = val == true),
),
],
);