diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 3ab0f16..86f5cad 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; 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/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -57,6 +58,33 @@ class UserService { return v.toString(); } + /// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif). + static MediaType _imageMediaType(String filename, List 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) { if (err == null) return 'Erreur inconnue'; 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). /// [parentUserId] : parent pivot du foyer (`parent_user_id`). + /// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON. static Future createEnfant({ required String parentUserId, required Map body, + List? photoBytes, + String? photoFilename, }) async { - final payload = { - ...body, - 'parent_user_id': parentUserId, - }; - final response = await http.post( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), - headers: await _headers(), - body: jsonEncode(payload), - ); + final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; + final http.Response response; + if (hasPhoto) { + final token = await TokenService.getToken(); + final req = http.MultipartRequest( + 'POST', + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + ); + 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 = { + ...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) { throw Exception( _extractErrorMessage(response.body, 'Erreur création enfant'), diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart index 17ac62e..3da8051 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -1,14 +1,27 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/api/api_config.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 { final String? photoUrl; + final Uint8List? imageBytes; + final VoidCallback? onTap; + final VoidCallback? onClear; + final String emptyLabel; 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). static double columnWidthForHeight(double height) { @@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget { ph = pw / ar; } + final hasLocal = imageBytes != null && imageBytes!.isNotEmpty; + final showClear = onClear != null && hasLocal; + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne // (évite le vide blanc en bas quand le conteneur parent est plus haut). - return Align( + Widget frame = Align( alignment: Alignment.topCenter, child: Container( 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) { + if (imageBytes != null && imageBytes!.isNotEmpty) { + return Image.memory( + imageBytes!, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ); + } if (fullUrl.isEmpty) { return ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), - Text( - 'Aucune photo', - style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), ), ], ), @@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget { }, errorBuilder: (_, __, ___) => ColoredBox( 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, + ), ), ); } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 561e3c6..2678ace 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + 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/enfant_admin_model.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; @@ -62,6 +65,10 @@ class _AdminChildDetailModalState extends State { /// Famille choisie en mode création (#132). AdminFamilleFoyer? _selectedFamily; + /// Photo locale (création) — upload multipart `photo`. + Uint8List? _photoBytes; + String? _photoFilename; + static const double _modalWidth = 930; static const double _mainRowHeight = 380; static const double _placementHeight = 96; @@ -370,11 +377,24 @@ class _AdminChildDetailModalState extends State { ); 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); try { await UserService.createEnfant( parentUserId: family.pivotParentUserId, + photoBytes: hasPhoto ? _photoBytes : null, + photoFilename: _photoFilename, body: { 'first_name': prenom, 'last_name': nom, @@ -383,8 +403,8 @@ class _AdminChildDetailModalState extends State { if (_isUnborn) if (_dateToIso(_dueCtrl.text) != null) 'due_date': _dateToIso(_dueCtrl.text) - else if (_dateToIso(_birthCtrl.text) != null) - 'birth_date': _dateToIso(_birthCtrl.text), + else if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, }, @@ -727,6 +747,43 @@ class _AdminChildDetailModalState extends State { ); } + Future _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() { return LayoutBuilder( builder: (context, c) { @@ -749,7 +806,20 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( 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), diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 09ded02..3672396 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -206,7 +206,7 @@ packages: source: hosted version: "1.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 0f2e0aa..b3f4ca8 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: js: ^0.6.7 url_launcher: ^6.2.4 http: ^1.2.2 + http_parser: ^4.0.2 # flutter_secure_storage: ^9.0.0 pdfx: ^2.5.0 universal_platform: ^1.1.0