feat(inscription AM #120): UI étape pro, photo unifiée, scripts et données test
- Panneau AM : validation NIR alignée API, date d’agrément requise côté app, capacité 1–10, n° agrément + date sur une ligne, ville/pays formatés au blur. - Widget RegistrationPhotoSlot (cadre, croix) partagé avec les cartes enfant. - AuthService : MIME PNG/JPEG pour la photo ; payload date_agrement. - Scripts register-am-dubois / mansouri ; chemins tests/ressources/photos ; doc test-data + seed ; smoke curl inscription AM. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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 (!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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user