POST /enfants pour gestionnaire/admin avec parent_user_id, liens foyer, multipart photo optionnel ; UI modale création + sélection famille. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
5.7 KiB
Dart
195 lines
5.7 KiB
Dart
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 / 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,
|
||
this.imageBytes,
|
||
this.onTap,
|
||
this.onClear,
|
||
this.emptyLabel = 'Aucune photo',
|
||
});
|
||
|
||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||
static double columnWidthForHeight(double height) {
|
||
const frame = 16.0;
|
||
final innerH = (height - frame).clamp(0.0, double.infinity);
|
||
return innerH * idPhotoAspectRatio + frame;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl);
|
||
return LayoutBuilder(
|
||
builder: (context, c) {
|
||
const uniformFrame = 8.0;
|
||
final maxPhotoW =
|
||
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||
final maxPhotoH =
|
||
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||
const ar = idPhotoAspectRatio;
|
||
|
||
double ph = maxPhotoH;
|
||
double pw = ph * ar;
|
||
if (pw > maxPhotoW) {
|
||
pw = maxPhotoW;
|
||
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).
|
||
Widget frame = Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade100,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(uniformFrame),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: SizedBox(
|
||
width: pw,
|
||
height: ph,
|
||
child: _photoContent(fullUrl, pw, ph),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
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(
|
||
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
||
size: 36,
|
||
color: Colors.grey.shade400,
|
||
),
|
||
const SizedBox(height: 6),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||
child: Text(
|
||
emptyLabel,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return AuthNetworkImage(
|
||
url: fullUrl,
|
||
width: pw,
|
||
height: ph,
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
loadingBuilder: (_, child, progress) {
|
||
if (progress == null) return child;
|
||
return ColoredBox(
|
||
color: Colors.grey.shade200,
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
value: progress.expectedTotalBytes != null
|
||
? progress.cumulativeBytesLoaded /
|
||
(progress.expectedTotalBytes!)
|
||
: null,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
errorBuilder: (_, __, ___) => ColoredBox(
|
||
color: Colors.grey.shade200,
|
||
child: Icon(
|
||
Icons.broken_image_outlined,
|
||
size: 36,
|
||
color: Colors.grey.shade400,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|