feat(#132): upload photo à la création d'enfant (admin).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-07-17 18:07:47 +02:00
parent 0ec3410457
commit 26e9738ec7
5 changed files with 235 additions and 21 deletions

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -57,6 +58,33 @@ class UserService {
return v.toString(); return v.toString();
} }
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
static MediaType _imageMediaType(String filename, List<int> bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return MediaType('image', 'jpeg');
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return MediaType('image', 'png');
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return MediaType('image', 'gif');
}
final lower = filename.toLowerCase();
if (lower.endsWith('.png')) return MediaType('image', 'png');
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
return MediaType('image', 'jpeg');
}
static String _errMessage(dynamic err) { static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue'; if (err == null) return 'Erreur inconnue';
if (err is String) return err; if (err is String) return err;
@ -517,19 +545,55 @@ class UserService {
/// Création enfant côté staff (#132) nécessite `POST /enfants` étendu (voir mini-spec). /// Création enfant côté staff (#132) nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`). /// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({ static Future<EnfantAdminModel> createEnfant({
required String parentUserId, required String parentUserId,
required Map<String, dynamic> body, required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async { }) async {
final payload = <String, dynamic>{ final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
...body, final http.Response response;
'parent_user_id': parentUserId, if (hasPhoto) {
}; final token = await TokenService.getToken();
final response = await http.post( final req = http.MultipartRequest(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), 'POST',
headers: await _headers(), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
body: jsonEncode(payload), );
); req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception( throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'), _extractErrorMessage(response.body, 'Erreur création enfant'),

View File

@ -1,14 +1,27 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM (35×45 mm) même logique que [ValidationAmWizard]. /// Cadre photo identité AM / enfant (35×45 mm) même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget { class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl; final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
final VoidCallback? onClear;
final String emptyLabel;
static const double idPhotoAspectRatio = 35 / 45; static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl}); const AdminAmPhotoFrame({
super.key,
this.photoUrl,
this.imageBytes,
this.onTap,
this.onClear,
this.emptyLabel = 'Aucune photo',
});
/// Largeur colonne photo pour remplir [height] (cadre inclus). /// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) { static double columnWidthForHeight(double height) {
@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
ph = pw / ar; ph = pw / ar;
} }
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
final showClear = onClear != null && hasLocal;
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne // Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut). // (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align( Widget frame = Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
), ),
), ),
); );
if (onTap != null) {
frame = MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: frame,
),
);
}
if (!showClear) return frame;
return Stack(
clipBehavior: Clip.none,
children: [
frame,
Positioned(
top: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer la photo',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: Icon(
Icons.cancel,
size: 22,
color: Colors.grey.shade700,
),
onPressed: onClear,
),
),
),
],
);
}, },
); );
} }
Widget _photoContent(String fullUrl, double pw, double ph) { Widget _photoContent(String fullUrl, double pw, double ph) {
if (imageBytes != null && imageBytes!.isNotEmpty) {
return Image.memory(
imageBytes!,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
);
}
if (fullUrl.isEmpty) { if (fullUrl.isEmpty) {
return ColoredBox( return ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), Icon(
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
size: 36,
color: Colors.grey.shade400,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Padding(
'Aucune photo', padding: const EdgeInsets.symmetric(horizontal: 6),
style: TextStyle(color: Colors.grey.shade600, fontSize: 11), child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
), ),
], ],
), ),
@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
}, },
errorBuilder: (_, __, ___) => ColoredBox( errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), child: Icon(
Icons.broken_image_outlined,
size: 36,
color: Colors.grey.shade400,
),
), ),
); );
} }

View File

@ -1,4 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
@ -62,6 +65,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// Famille choisie en mode création (#132). /// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily; AdminFamilleFoyer? _selectedFamily;
/// Photo locale (création) upload multipart `photo`.
Uint8List? _photoBytes;
String? _photoFilename;
static const double _modalWidth = 930; static const double _modalWidth = 930;
static const double _mainRowHeight = 380; static const double _mainRowHeight = 380;
static const double _placementHeight = 96; static const double _placementHeight = 96;
@ -370,11 +377,24 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
); );
return; return;
} }
final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty;
if (hasPhoto && !_consentPhoto) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Activez le consentement photo pour enregistrer une photo',
),
),
);
return;
}
setState(() => _saving = true); setState(() => _saving = true);
try { try {
await UserService.createEnfant( await UserService.createEnfant(
parentUserId: family.pivotParentUserId, parentUserId: family.pivotParentUserId,
photoBytes: hasPhoto ? _photoBytes : null,
photoFilename: _photoFilename,
body: { body: {
'first_name': prenom, 'first_name': prenom,
'last_name': nom, 'last_name': nom,
@ -383,8 +403,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (_isUnborn) if (_isUnborn)
if (_dateToIso(_dueCtrl.text) != null) if (_dateToIso(_dueCtrl.text) != null)
'due_date': _dateToIso(_dueCtrl.text) 'due_date': _dateToIso(_dueCtrl.text)
else if (_dateToIso(_birthCtrl.text) != null) else if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text), 'birth_date': _dateToIso(_birthCtrl.text),
'consent_photo': _consentPhoto, 'consent_photo': _consentPhoto,
'is_multiple': _isMultiple, 'is_multiple': _isMultiple,
}, },
@ -727,6 +747,43 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
); );
} }
Future<void> _pickPhoto() async {
if (_busy || !widget.isCreating) return;
try {
final picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
imageQuality: 75,
maxWidth: 1200,
maxHeight: 1200,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
if (bytes.isEmpty) return;
final name = picked.name.trim();
if (!mounted) return;
setState(() {
_photoBytes = bytes;
_photoFilename = name.isNotEmpty ? name : 'photo.jpg';
_consentPhoto = true;
_dirty = true;
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger la photo')),
);
}
}
void _clearPhoto() {
if (_busy || !widget.isCreating) return;
setState(() {
_photoBytes = null;
_photoFilename = null;
_dirty = true;
});
}
Widget _mainRow() { Widget _mainRow() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
@ -749,7 +806,20 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
children: [ children: [
Expanded( Expanded(
child: AdminAmPhotoFrame( child: AdminAmPhotoFrame(
photoUrl: widget.enfant?.photoUrl, photoUrl: widget.isCreating
? null
: widget.enfant?.photoUrl,
imageBytes: widget.isCreating ? _photoBytes : null,
onTap: widget.isCreating && !_busy ? _pickPhoto : null,
onClear: widget.isCreating &&
!_busy &&
_photoBytes != null &&
_photoBytes!.isNotEmpty
? _clearPhoto
: null,
emptyLabel: widget.isCreating
? 'Cliquer pour ajouter'
: 'Aucune photo',
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@ -206,7 +206,7 @@ packages:
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
http_parser: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"

View File

@ -21,6 +21,7 @@ dependencies:
js: ^0.6.7 js: ^0.6.7
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: ^1.2.2 http: ^1.2.2
http_parser: ^4.0.2
# flutter_secure_storage: ^9.0.0 # flutter_secure_storage: ^9.0.0
pdfx: ^2.5.0 pdfx: ^2.5.0
universal_platform: ^1.1.0 universal_platform: ^1.1.0