petitspas/frontend/lib/widgets/registration_photo_slot.dart
Julien Martin 9b7231f1da fix(#112): afficher photos enfants en reprise + consentement cohérent
Charge existingPhotoUrl dans les cartes enfant (étapes 3 et 5) et pré-coche
le consentement photo lorsqu'une photo est déjà en base.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:00:15 +02:00

173 lines
5.5 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'hover_relief_widget.dart';
/// Cadre « croquis » par-dessus la photo (même ressource que les cartes enfant).
const String kRegistrationPhotoFrameAsset = 'assets/images/photo_frame.png';
/// Indique si une photo « réelle » est présente (hors seul placeholder asset).
bool registrationPhotoSlotHasImage({
Uint8List? imageBytes,
File? imageFile,
String? imagePathOrAsset,
}) {
if (imageBytes != null && imageBytes.isNotEmpty) return true;
if (imageFile != null) return true;
if (imagePathOrAsset == null || imagePathOrAsset.isEmpty) return false;
return !imagePathOrAsset.startsWith('assets/');
}
/// Zone photo uniforme : cadre gris / placeholder, tap pour choisir, croix pour retirer, cadre dessin si photo.
///
/// Utilisé inscription **enfants** ([ChildCardWidget]) et **AM** ([ProfessionalInfoFormScreen]).
class RegistrationPhotoSlot extends StatelessWidget {
final double side;
final double scaleFactor;
final Uint8List? imageBytes;
final File? imageFile;
/// Chemin fichier local (hors `assets/`) ou URI asset `assets/...`.
final String? imagePathOrAsset;
final VoidCallback? onTapPick;
final VoidCallback? onClear;
final Color baseShadowColor;
final String placeholderAsset;
final bool isMobile;
const RegistrationPhotoSlot({
super.key,
required this.side,
this.scaleFactor = 1.0,
this.imageBytes,
this.imageFile,
this.imagePathOrAsset,
this.onTapPick,
this.onClear,
required this.baseShadowColor,
this.placeholderAsset = 'assets/images/photo.png',
this.isMobile = false,
});
bool get _hasImage => registrationPhotoSlotHasImage(
imageBytes: imageBytes,
imageFile: imageFile,
imagePathOrAsset: imagePathOrAsset,
);
Widget _buildImage({required BoxFit fit}) {
final b = imageBytes;
if (b != null && b.isNotEmpty) {
return Image.memory(b, fit: fit);
}
final f = imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
final p = imagePathOrAsset;
if (p != null && p.isNotEmpty) {
if (p.startsWith('assets/')) {
return Image.asset(p, fit: fit);
}
if (p.startsWith('http://') || p.startsWith('https://')) {
return Image.network(p, fit: fit);
}
if (!kIsWeb) {
try {
final file = File(p);
if (file.existsSync()) {
return Image.file(file, fit: fit);
}
} catch (_) {}
}
}
return Image.asset(placeholderAsset, fit: BoxFit.contain);
}
@override
Widget build(BuildContext context) {
final canPick = onTapPick != null;
final canClear = onClear != null && _hasImage;
final outerRadius = BorderRadius.circular(10 * scaleFactor);
final photoClipRadius = BorderRadius.circular(side * 0.14);
final initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseShadowColor);
final hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseShadowColor);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canPick ? onTapPick : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
clipBehavior: _hasImage ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: side,
height: side,
child: !_hasImage
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
placeholderAsset,
fit: BoxFit.contain,
width: side,
height: side,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildImage(fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
kRegistrationPhotoFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (canClear)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onClear,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: isMobile ? 26 : 30,
height: isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
}