petitspas/frontend/lib/widgets/child_card_widget.dart
Julien Martin b18d5c8a9e feat(frontend): Refonte infrastructure formulaires multi-modes
- Support des modes Desktop/Mobile et Édition/Lecture seule
- Refactoring des widgets de formulaire (PersonalInfo, ProfessionalInfo, Presentation, ChildCard)
- Mise à jour des écrans de récapitulatif (ParentStep5, AmStep4)
- Ajout de navigation (Précédent/Soumettre) sur mobile

Closes #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:51:33 +01:00

592 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import '../models/user_registration_data.dart';
import '../models/card_assets.dart';
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart';
import '../config/display_config.dart';
/// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents
class ChildCardWidget extends StatefulWidget {
final ChildData childData;
final int childIndex;
final VoidCallback onPickImage;
final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged;
final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove;
final bool canBeRemoved;
final DisplayMode mode;
final VoidCallback? onEdit;
const ChildCardWidget({
required Key key,
required this.childData,
required this.childIndex,
required this.onPickImage,
required this.onDateSelect,
required this.onFirstNameChanged,
required this.onLastNameChanged,
required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn,
required this.onRemove,
required this.canBeRemoved,
this.mode = DisplayMode.editable,
this.onEdit,
}) : super(key: key);
@override
State<ChildCardWidget> createState() => _ChildCardWidgetState();
}
class _ChildCardWidgetState extends State<ChildCardWidget> {
late TextEditingController _firstNameController;
late TextEditingController _lastNameController;
late TextEditingController _dobController;
@override
void initState() {
super.initState();
// Initialiser les contrôleurs avec les données du widget
_firstNameController = TextEditingController(text: widget.childData.firstName);
_lastNameController = TextEditingController(text: widget.childData.lastName);
_dobController = TextEditingController(text: widget.childData.dob);
// Ajouter des listeners pour mettre à jour les données sources via les callbacks
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
}
@override
void didUpdateWidget(covariant ChildCardWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Mettre à jour les contrôleurs si les données externes changent
// (peut arriver si on recharge l'état global)
if (widget.childData.firstName != _firstNameController.text) {
_firstNameController.text = widget.childData.firstName;
}
if (widget.childData.lastName != _lastNameController.text) {
_lastNameController.text = widget.childData.lastName;
}
if (widget.childData.dob != _dobController.text) {
_dobController.text = widget.childData.dob;
}
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_dobController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = DisplayConfig.fromContext(context, mode: widget.mode);
final screenSize = MediaQuery.of(context).size;
final scaleFactor = config.isMobile ? 0.9 : 1.1; // Réduire légèrement sur mobile
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal
if (config.isReadonly && !config.isMobile) {
return _buildReadonlyDesktopCard(context, config, screenSize);
}
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical (1:2)
if (config.isReadonly && config.isMobile) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
child: _buildReadonlyMobileCard(context, config),
);
}
final File? currentChildImage = widget.childData.imageFile;
// ... (reste du code existant pour mobile/editable)
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
return Container(
width: config.isMobile ? double.infinity : screenSize.width * 0.6,
// On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
// height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor),
decoration: BoxDecoration(
image: DecorationImage(image: AssetImage(widget.childData.cardColor.path), fit: BoxFit.fill),
borderRadius: BorderRadius.circular(20 * scaleFactor),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// ... (contenu existant)
HoverReliefWidget(
onPressed: config.isReadonly ? null : widget.onPickImage,
borderRadius: BorderRadius.circular(10),
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
child: SizedBox(
height: 200.0 * (config.isMobile ? 0.8 : 1.0),
width: 200.0 * (config.isMobile ? 0.8 : 1.0),
child: Center(
child: Padding(
padding: EdgeInsets.all(5.0 * scaleFactor),
child: currentChildImage != null
? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
),
SizedBox(height: 10.0 * scaleFactor),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Enfant à naître ?',
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 14 : 16 * scaleFactor,
fontWeight: FontWeight.w600
)
),
Transform.scale(
scale: config.isMobile ? 0.8 : 1.0,
child: Switch(
value: widget.childData.isUnbornChild,
onChanged: config.isReadonly ? null : widget.onToggleIsUnborn,
activeColor: Theme.of(context).primaryColor,
),
),
],
),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
config: config,
scaleFactor: scaleFactor,
label: 'Prénom',
controller: _firstNameController,
hint: 'Facultatif si à naître',
isRequired: !widget.childData.isUnbornChild,
),
SizedBox(height: 5.0 * scaleFactor),
_buildField(
config: config,
scaleFactor: scaleFactor,
label: 'Nom',
controller: _lastNameController,
hint: 'Nom de l\'enfant',
),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
config: config,
scaleFactor: scaleFactor,
label: widget.childData.isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
controller: _dobController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: config.isReadonly ? null : widget.onDateSelect,
suffixIcon: Icons.calendar_today,
),
SizedBox(height: 10.0 * scaleFactor),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
SizedBox(height: 5.0 * scaleFactor),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
],
),
],
),
if (widget.canBeRemoved && !config.isReadonly)
Positioned(
top: -5, right: -5,
child: InkWell(
onTap: widget.onRemove,
customBorder: const CircleBorder(),
child: Image.asset(
'assets/images/red_cross2.png',
width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36,
fit: BoxFit.contain,
),
),
),
if (config.isReadonly && widget.onEdit != null)
Positioned(
top: -5, right: -5,
child: IconButton(
icon: const Icon(Icons.edit, color: Colors.black54),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
),
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
// On mappe les couleurs verticales vers horizontales
String horizontalCardAsset = CardColorHorizontal.lavender.path; // Par défaut
// Mapping manuel simple
if (widget.childData.cardColor.path.contains('lavender')) horizontalCardAsset = CardColorHorizontal.lavender.path;
else if (widget.childData.cardColor.path.contains('blue')) horizontalCardAsset = CardColorHorizontal.blue.path;
else if (widget.childData.cardColor.path.contains('green')) horizontalCardAsset = CardColorHorizontal.green.path;
else if (widget.childData.cardColor.path.contains('lime')) horizontalCardAsset = CardColorHorizontal.lime.path;
else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path;
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path;
else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
final File? currentChildImage = widget.childData.imageFile;
final cardWidth = screenSize.width / 2.0;
return SizedBox(
width: cardWidth,
child: AspectRatio(
aspectRatio: 2.0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(horizontalCardAsset),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(15),
),
child: Column(
children: [
// Titre + Edit Button
Row(
children: [
Expanded(
child: Text(
'Enfant ${widget.childIndex + 1}' + (widget.childData.isUnbornChild ? ' (à naître)' : ''),
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
if (widget.onEdit != null)
IconButton(
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
],
),
const SizedBox(height: 18),
// Contenu principal : Photo + Champs
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PHOTO (1/3)
Expanded(
flex: 1,
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: currentChildImage != null
? (kIsWeb
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
),
),
const SizedBox(width: 32),
// CHAMPS (2/3)
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildReadonlyField('Prénom :', _firstNameController.text),
const SizedBox(height: 12),
_buildReadonlyField('Nom :', _lastNameController.text),
const SizedBox(height: 12),
_buildReadonlyField(
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text
),
],
),
),
],
),
),
const SizedBox(height: 18),
// Consentements
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
const SizedBox(width: 32),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
],
),
],
),
),
),
);
}
/// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
final File? currentChildImage = widget.childData.imageFile;
return Container(
width: double.infinity,
// Pas de height fixe
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(widget.childData.cardColor.path), // Image verticale
fit: BoxFit.fill, // Fill pour s'adapter
),
borderRadius: BorderRadius.circular(15),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min, // S'adapte au contenu
children: [
// Titre + Edit Button
Row(
children: [
Expanded(
child: Text(
'Enfant ${widget.childIndex + 1}' + (widget.childData.isUnbornChild ? ' (à naître)' : ''),
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
),
if (widget.onEdit != null)
const SizedBox(width: 28),
],
),
// Contenu aligné en haut
Padding(
padding: const EdgeInsets.only(top: 20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Photo
SizedBox(
height: 150,
width: 150,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: currentChildImage != null
? (kIsWeb
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
const SizedBox(height: 16),
// Champs
_buildReadonlyField('Prénom :', _firstNameController.text),
const SizedBox(height: 8),
_buildReadonlyField('Nom :', _lastNameController.text),
const SizedBox(height: 8),
_buildReadonlyField(
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text
),
const SizedBox(height: 16),
// Consentements
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
const SizedBox(height: 8),
Row(
children: [
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
],
),
],
),
),
],
),
if (widget.onEdit != null)
Positioned(
top: 0,
right: 0,
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
),
);
}
/// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 22.0, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
height: 50.0,
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('assets/images/bg_beige.png'),
fit: BoxFit.fill,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: 18.0),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildField({
required DisplayConfig config,
required double scaleFactor,
required String label,
required TextEditingController controller,
String? hint,
bool isRequired = false,
bool readOnly = false,
VoidCallback? onTap,
IconData? suffixIcon,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
config: config,
label: label,
value: controller.text,
);
} else {
return CustomAppTextField(
controller: controller,
labelText: label,
hintText: hint ?? label,
isRequired: isRequired,
fieldHeight: config.isMobile ? 40.0 : 50.0 * scaleFactor, // Hauteur réduite
labelFontSize: config.isMobile ? 12.0 : 18.0, // Police réduite
inputFontSize: config.isMobile ? 13.0 : 16.0, // Police réduite
readOnly: readOnly,
onTap: onTap,
suffixIcon: suffixIcon,
);
}
}
}