feat(#132): upload photo à la création d'enfant (admin).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AdminChildDetailModal> {
|
||||
/// 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<AdminChildDetailModal> {
|
||||
);
|
||||
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<AdminChildDetailModal> {
|
||||
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<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() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
@@ -749,7 +806,20 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user