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:
parent
cdae9b5f7c
commit
01c1be8f16
@ -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*
|
||||
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 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;
|
||||
|
||||
@ -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,
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user