feat(auth): Amélioration UI/UX étape 3 inscription enfants

- Corrige le débordement visuel (RenderFlex overflow) dans les cartes enfants.

- Augmente les marges latérales du sélecteur d'enfants pour un meilleur centrage.

- Ajoute un défilement automatique vers la droite lors de l'ajout d'un enfant.

- Intègre une barre de défilement horizontale et un effet de fondu dynamique (fading edges) au sélecteur d'enfants.

- Ajuste le padding vertical dans CustomAppTextField pour un meilleur centrage du hintText.

- Met à jour index.html :

  - Utilise le token {{flutter_service_worker_version}}.

  - Ajoute la balise meta mobile-web-app-capable.

  - Rétablit temporairement loadEntrypoint pour éviter un écran blanc (avertissement de dépréciation en attente de correction).
This commit is contained in:
Julien Martin 2025-05-07 10:42:52 +02:00
parent df56ba11df
commit 42d147c273
4 changed files with 485 additions and 333 deletions

View File

@ -6,9 +6,40 @@ import 'package:image_picker/image_picker.dart';
// import 'package:image_cropper/image_cropper.dart'; // Supprimé // import 'package:image_cropper/image_cropper.dart'; // Supprimé
import 'dart:io' show File, Platform; // Ajout de Platform import 'dart:io' show File, Platform; // Ajout de Platform
import 'package:flutter/foundation.dart' show kIsWeb; // Import pour kIsWeb import 'package:flutter/foundation.dart' show kIsWeb; // Import pour kIsWeb
import '../../widgets/custom_app_text_field.dart'; // Import du nouveau widget TextField
import '../../widgets/app_custom_checkbox.dart'; // Import du nouveau widget Checkbox
// TODO: Créer un modèle de données pour l'enfant // Classe de données pour un enfant
// class ChildData { ... } class _ChildFormData {
final Key key; // Pour aider Flutter à identifier les widgets dans une liste
final TextEditingController firstNameController;
final TextEditingController lastNameController;
final TextEditingController dobController;
bool photoConsent;
bool multipleBirth;
bool isUnbornChild;
File? imageFile;
_ChildFormData({
required this.key,
String initialFirstName = '',
String initialLastName = '',
String initialDob = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
}) : firstNameController = TextEditingController(text: initialFirstName),
lastNameController = TextEditingController(text: initialLastName),
dobController = TextEditingController(text: initialDob);
// Méthode pour disposer les contrôleurs
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
dobController.dispose();
}
}
class ParentRegisterStep3Screen extends StatefulWidget { class ParentRegisterStep3Screen extends StatefulWidget {
const ParentRegisterStep3Screen({super.key}); const ParentRegisterStep3Screen({super.key});
@ -22,60 +53,127 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
// List<ChildData> _children = [ChildData()]; // Commencer avec un enfant // List<ChildData> _children = [ChildData()]; // Commencer avec un enfant
final _formKey = GlobalKey<FormState>(); // Une clé par enfant sera nécessaire si validation complexe final _formKey = GlobalKey<FormState>(); // Une clé par enfant sera nécessaire si validation complexe
// Contrôleurs pour le premier enfant (pour l'instant) // Liste pour stocker les données de chaque enfant
final _firstNameController = TextEditingController(); List<_ChildFormData> _childrenDataList = [];
final _lastNameController = TextEditingController(); final ScrollController _scrollController = ScrollController(); // Ajout du ScrollController
final _dobController = TextEditingController(); bool _isScrollable = false;
bool _photoConsent = false; bool _showLeftFade = false;
bool _multipleBirth = false; bool _showRightFade = false;
bool _isUnbornChild = false; // Nouvelle variable d'état static const double _fadeExtent = 0.05; // Pourcentage de la vue pour le fondu (5%)
// TODO: Ajouter variable pour stocker l'image sélectionnée (par enfant)
// File? _childImage;
// File? _childImage; // Déjà présent et commenté
// Liste pour stocker les images des enfants (si gestion multi-enfants)
List<File?> _childImages = [null]; // Initialiser avec null pour le premier enfant
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_dobController.dispose();
// TODO: Disposer les contrôleurs de tous les enfants
super.dispose();
}
// TODO: Pré-remplir le nom de famille avec celui du parent 1
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_addChild();
_scrollController.addListener(_scrollListener);
// Appel initial pour définir l'état des fondus après le premier layout
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
} }
Future<void> _selectDate(BuildContext context) async { @override
void dispose() {
// Disposer les contrôleurs de tous les enfants
for (var childData in _childrenDataList) {
childData.dispose();
}
_scrollController.removeListener(_scrollListener); // Ne pas oublier de retirer le listener
_scrollController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return; // S'assurer que le controller est attaché
final position = _scrollController.position;
final newIsScrollable = position.maxScrollExtent > 0.0;
// Le fondu à gauche est affiché si on a scrollé plus loin que la moitié de la zone de fondu
final newShowLeftFade = newIsScrollable && position.pixels > (position.viewportDimension * _fadeExtent / 2);
// Le fondu à droite est affiché s'il reste à scroller plus que la moitié de la zone de fondu
final newShowRightFade = newIsScrollable && position.pixels < (position.maxScrollExtent - (position.viewportDimension * _fadeExtent / 2));
if (newIsScrollable != _isScrollable ||
newShowLeftFade != _showLeftFade ||
newShowRightFade != _showRightFade) {
setState(() {
_isScrollable = newIsScrollable;
_showLeftFade = newShowLeftFade;
_showRightFade = newShowRightFade;
});
}
}
void _addChild() {
String initialLastName = '';
if (_childrenDataList.isNotEmpty) {
initialLastName = _childrenDataList.first.lastNameController.text;
}
setState(() {
_childrenDataList.add(_ChildFormData(
key: UniqueKey(),
initialLastName: initialLastName,
));
});
// S'assurer que le listener est appelé après la mise à jour de l'UI
// et faire défiler vers la fin si possible
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollListener(); // Mettre à jour l'état des fondus
if (_scrollController.hasClients && _scrollController.position.maxScrollExtent > 0.0) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
// Méthode pour sélectionner une image (devra être adaptée pour l'index)
Future<void> _pickImage(int childIndex) async {
final ImagePicker picker = ImagePicker();
try {
final XFile? pickedFile = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 70,
maxWidth: 1024,
maxHeight: 1024,
);
if (pickedFile != null) {
setState(() {
if (childIndex < _childrenDataList.length) {
_childrenDataList[childIndex].imageFile = File(pickedFile.path);
}
});
} // Fin de if (pickedFile != null)
} catch (e) {
print("Erreur lors de la sélection de l'image: $e");
}
}
Future<void> _selectDate(BuildContext context, int childIndex) async {
final _ChildFormData currentChild = _childrenDataList[childIndex];
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
DateTime initialDatePickerDate = now; DateTime initialDatePickerDate = now;
DateTime firstDatePickerDate = DateTime(1980); DateTime firstDatePickerDate = DateTime(1980);
DateTime lastDatePickerDate = now; DateTime lastDatePickerDate = now;
if (_isUnbornChild) { if (currentChild.isUnbornChild) {
firstDatePickerDate = now; // Ne peut pas être avant aujourd'hui si à naître firstDatePickerDate = now;
lastDatePickerDate = now.add(const Duration(days: 300)); // Environ 10 mois dans le futur lastDatePickerDate = now.add(const Duration(days: 300));
// Si une date de naissance avait é entrée, on la garde pour initialDate si elle est dans la nouvelle plage if (currentChild.dobController.text.isNotEmpty) {
if (_dobController.text.isNotEmpty) {
try { try {
// Tenter de parser la date existante List<String> parts = currentChild.dobController.text.split('/');
List<String> parts = _dobController.text.split('/');
DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}"); DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}");
if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) { if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) {
initialDatePickerDate = parsedDate; initialDatePickerDate = parsedDate;
} }
} catch (e) { /* Ignorer si le format est incorrect */ } } catch (e) { /* Ignorer */ }
} }
} else { } else {
// Si une date prévisionnelle avait é entrée, on la garde pour initialDate si elle est dans la nouvelle plage if (currentChild.dobController.text.isNotEmpty) {
if (_dobController.text.isNotEmpty) {
try { try {
List<String> parts = _dobController.text.split('/'); List<String> parts = currentChild.dobController.text.split('/');
DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}"); DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}");
if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) { if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) {
initialDatePickerDate = parsedDate; initialDatePickerDate = parsedDate;
@ -93,135 +191,145 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
); );
if (picked != null) { if (picked != null) {
setState(() { setState(() {
_dobController.text = "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}"; currentChild.dobController.text = "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}";
}); });
} }
} }
// Méthode pour sélectionner une image void _removeChild(Key key) {
Future<void> _pickImage(int childIndex) async { setState(() {
final ImagePicker picker = ImagePicker(); // Trouver et supprimer l'enfant par sa clé, et s'assurer qu'il en reste au moins un.
try { if (_childrenDataList.length > 1) {
final XFile? pickedFile = await picker.pickImage( _childrenDataList.removeWhere((child) => child.key == key);
source: ImageSource.gallery, }
imageQuality: 70, });
maxWidth: 1024, // S'assurer que le listener est appelé après la mise à jour de l'UI
maxHeight: 1024, WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
);
if (pickedFile != null) {
// On utilise directement le fichier sélectionné, sans recadrage
setState(() {
if (childIndex < _childImages.length) {
_childImages[childIndex] = File(pickedFile.path);
} else {
print("Erreur: Index d'enfant hors limites pour l'image.");
}
});
} // Fin de if (pickedFile != null)
} catch (e) {
print("Erreur lors de la sélection de l'image: $e");
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
// Fond papier
Positioned.fill( Positioned.fill(
child: Image.asset( child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
'assets/images/paper2.png',
fit: BoxFit.cover,
repeat: ImageRepeat.repeat,
),
), ),
// Contenu centré et scrollable
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0), // Ajout de padding vertical padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Indicateur d'étape Text('Étape 3/X', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
Text(
'Étape 3/X', // Mettre à jour le numéro d'étape total
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
),
const SizedBox(height: 10), const SizedBox(height: 10),
// Texte d'instruction
Text( Text(
'Merci de renseigner les informations de/vos enfant(s) :', 'Merci de renseigner les informations de/vos enfant(s) :',
style: GoogleFonts.merienda( style: GoogleFonts.merienda(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
Padding( // Ajout du Padding pour les marges latérales
padding: const EdgeInsets.symmetric(horizontal: 150.0), // Marge de 150px de chaque côté
child: SizedBox(
height: 500,
child: ShaderMask(
shaderCallback: (Rect bounds) {
// Déterminer les couleurs du gradient en fonction de l'état de défilement
final Color leftFade = (_isScrollable && _showLeftFade) ? Colors.transparent : Colors.black;
final Color rightFade = (_isScrollable && _showRightFade) ? Colors.transparent : Colors.black;
// Si ce n'est pas scrollable du tout, pas de fondu.
if (!_isScrollable) {
return LinearGradient(
colors: const <Color>[Colors.black, Colors.black, Colors.black, Colors.black],
stops: const [0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0],
).createShader(bounds);
}
// Zone principale : Cartes enfants + Bouton Ajouter return LinearGradient(
// Utilisation d'une Row pour placer côte à côte comme sur la maquette begin: Alignment.centerLeft,
// Il faudra peut-être ajuster pour les petits écrans (Wrap?) end: Alignment.centerRight,
Row( colors: <Color>[
mainAxisAlignment: MainAxisAlignment.center, leftFade, // Bord gauche
crossAxisAlignment: CrossAxisAlignment.center, // CHANGED: pour centrer verticalement Colors.black, // Devient opaque
children: [ Colors.black, // Reste opaque
// TODO: Remplacer par une ListView ou Column dynamique basée sur _children rightFade // Bord droit
// Pour l'instant, une seule carte ],
_buildChildCard(context, 0), // Index 0 pour le premier enfant stops: const [0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0], // 5% de fondu sur chaque bord
).createShader(bounds);
const SizedBox(width: 30),
HoverReliefWidget(
onPressed: () {
print("Ajouter un enfant via HoverReliefWidget");
// setState(() { _children.add(ChildData()); });
}, },
borderRadius: BorderRadius.circular(15), blendMode: BlendMode.dstIn,
child: Image.asset( child: Scrollbar( // Ajout du Scrollbar
'assets/images/plus.png', controller: _scrollController, // Utiliser le même contrôleur
height: 80, thumbVisibility: true, // Rendre la thumb toujours visible pour le web si souhaité, ou la laisser adaptative
width: 80, child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
itemCount: _childrenDataList.length + 1,
itemBuilder: (context, index) {
if (index < _childrenDataList.length) {
// Carte Enfant
return Padding(
padding: const EdgeInsets.only(right: 20.0), // Espace entre les cartes
child: _ChildCardWidget(
key: _childrenDataList[index].key, // Passer la clé unique
childData: _childrenDataList[index],
childIndex: index,
onPickImage: () => _pickImage(index),
onDateSelect: () => _selectDate(context, index),
onTogglePhotoConsent: (newValue) {
setState(() => _childrenDataList[index].photoConsent = newValue);
},
onToggleMultipleBirth: (newValue) {
setState(() => _childrenDataList[index].multipleBirth = newValue);
},
onToggleIsUnborn: (newValue) {
setState(() => _childrenDataList[index].isUnbornChild = newValue);
},
onRemove: () => _removeChild(_childrenDataList[index].key),
canBeRemoved: _childrenDataList.length > 1,
),
);
} else {
// Bouton Ajouter
return Center( // Pour centrer le bouton dans l'espace disponible
child: HoverReliefWidget(
onPressed: _addChild,
borderRadius: BorderRadius.circular(15),
child: Image.asset('assets/images/plus.png', height: 80, width: 80),
),
);
}
},
),
), ),
), ),
], ),
), ),
const SizedBox(height: 20), // Espace optionnel après la liste
], ],
), ),
), ),
), ),
// Chevrons de navigation
// Chevrons de navigation (identiques aux étapes précédentes)
// Chevron Gauche (Retour Step 2)
Positioned( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
left: 40, left: 40,
child: IconButton( child: IconButton(
icon: Transform( icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
alignment: Alignment.center, onPressed: () => Navigator.pop(context),
transform: Matrix4.rotationY(math.pi),
child: Image.asset('assets/images/chevron_right.png', height: 40),
),
onPressed: () => Navigator.pop(context), // Retour étape 2
tooltip: 'Retour', tooltip: 'Retour',
), ),
), ),
// Chevron Droit (Suivant Step 4)
Positioned( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
right: 40, right: 40,
child: IconButton( child: IconButton(
icon: Image.asset('assets/images/chevron_right.png', height: 40), icon: Image.asset('assets/images/chevron_right.png', height: 40),
onPressed: () { onPressed: () {
// TODO: Valider les infos enfants et Naviguer vers l'étape 4
print('Passer à l\'étape 4 (Situation familiale)'); print('Passer à l\'étape 4 (Situation familiale)');
// Navigator.pushNamed(context, '/parent-register/step4'); // Navigator.pushNamed(context, '/parent-register/step4');
}, },
@ -232,241 +340,138 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
), ),
); );
} }
}
// Widget pour construire UNE carte enfant // Nouveau Widget pour la carte enfant
// L'index permettra de lier aux bons contrôleurs et données class _ChildCardWidget extends StatelessWidget {
Widget _buildChildCard(BuildContext context, int index) { final _ChildFormData childData;
final File? currentChildImage = (index < _childImages.length) ? _childImages[index] : null; final int childIndex; // Utile pour certains callbacks ou logging
final VoidCallback onPickImage;
final VoidCallback onDateSelect;
final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove; // Callback pour supprimer la carte
final bool canBeRemoved; // Pour afficher/cacher le bouton de suppression
// TODO: Déterminer la couleur de base de card_lavander.png et ajuster ces couleurs d'ombre const _ChildCardWidget({
final Color baseLavandeColor = Colors.purple.shade200; // Placeholder pour la couleur de la carte lavande required Key key, // Important pour le ListView.builder
required this.childData,
required this.childIndex,
required this.onPickImage,
required this.onDateSelect,
required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn,
required this.onRemove,
required this.canBeRemoved,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final File? currentChildImage = childData.imageFile;
final Color baseLavandeColor = Colors.purple.shade200;
final Color initialPhotoShadow = baseLavandeColor.withAlpha(90); final Color initialPhotoShadow = baseLavandeColor.withAlpha(90);
final Color hoverPhotoShadow = baseLavandeColor.withAlpha(130); final Color hoverPhotoShadow = baseLavandeColor.withAlpha(130);
return Container( return Container(
width: 300, width: 300,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
image: const DecorationImage( image: const DecorationImage(image: AssetImage('assets/images/card_lavander.png'), fit: BoxFit.cover),
image: AssetImage('assets/images/card_lavander.png'),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Column( child: Stack( // Stack pour pouvoir superposer le bouton de suppression
mainAxisSize: MainAxisSize.min,
children: [ children: [
HoverReliefWidget(
onPressed: () {
_pickImage(index);
},
borderRadius: BorderRadius.circular(10),
initialShadowColor: initialPhotoShadow, // Ombre lavande
hoverShadowColor: hoverPhotoShadow, // Ombre lavande au survol
child: SizedBox(
height: 100,
width: 100,
child: Center(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: currentChildImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: kIsWeb // Condition pour le Web
? Image.network( // Utiliser Image.network pour le Web
currentChildImage.path,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Optionnel: Afficher un placeholder ou un message en cas d'erreur de chargement
print("Erreur de chargement de l'image réseau: $error");
return const Icon(Icons.broken_image, size: 40);
},
)
: Image.file( // Utiliser Image.file pour les autres plateformes
currentChildImage,
fit: BoxFit.cover,
),
)
: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
),
),
),
),
),
const SizedBox(height: 10), // Espace après la photo
// Nouveau Switch pour "Enfant à naître ?"
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // Aligner le label à gauche, switch à droite
children: [
Text(
'Enfant à naître ?',
style: GoogleFonts.merienda(fontSize: 14, fontWeight: FontWeight.w600),
),
Switch(
value: _isUnbornChild,
onChanged: (bool newValue) {
setState(() {
_isUnbornChild = newValue;
// Optionnel: Réinitialiser la date si le type change
// _dobController.clear();
});
},
activeColor: Theme.of(context).primaryColor, // Utiliser une couleur de thème
),
],
),
const SizedBox(height: 15), // Espace après le switch
_buildTextField(
_firstNameController,
'Prénom',
hintText: _isUnbornChild ? 'Prénom (optionnel)' : 'Prénom de l\'enfant', // HintText ajusté
isRequired: !_isUnbornChild,
),
const SizedBox(height: 10),
_buildTextField(_lastNameController, 'Nom', hintText: 'Nom de l\'enfant', enabled: true),
const SizedBox(height: 10),
_buildTextField(
_dobController,
_isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
hintText: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
),
const SizedBox(height: 20),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildCustomCheckbox( HoverReliefWidget(
label: 'Consentement photo', onPressed: onPickImage,
value: _photoConsent, borderRadius: BorderRadius.circular(10),
onChanged: (newValue) { initialShadowColor: initialPhotoShadow,
setState(() => _photoConsent = newValue); hoverShadowColor: hoverPhotoShadow,
} child: SizedBox(
height: 100, width: 100,
child: Center(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: currentChildImage != null
? ClipRRect(borderRadius: BorderRadius.circular(10), child: 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: 10), const SizedBox(height: 10),
_buildCustomCheckbox( Row(
label: 'Naissance multiple', mainAxisAlignment: MainAxisAlignment.spaceBetween,
value: _multipleBirth, children: [
onChanged: (newValue) { Text('Enfant à naître ?', style: GoogleFonts.merienda(fontSize: 14, fontWeight: FontWeight.w600)),
setState(() => _multipleBirth = newValue); Switch(value: childData.isUnbornChild, onChanged: onToggleIsUnborn, activeColor: Theme.of(context).primaryColor),
} ],
),
const SizedBox(height: 15),
CustomAppTextField( // Utilisation du nouveau widget
controller: childData.firstNameController,
label: 'Prénom',
hintText: childData.isUnbornChild ? 'Prénom (optionnel)' : 'Prénom de l\'enfant',
isRequired: !childData.isUnbornChild,
),
const SizedBox(height: 10),
CustomAppTextField( // Utilisation du nouveau widget
controller: childData.lastNameController,
label: 'Nom',
hintText: 'Nom de l\'enfant',
enabled: true,
),
const SizedBox(height: 10),
CustomAppTextField( // Utilisation du nouveau widget
controller: childData.dobController,
label: childData.isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
hintText: 'JJ/MM/AAAA',
readOnly: true,
onTap: onDateSelect,
suffixIcon: Icons.calendar_today,
),
const SizedBox(height: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCustomCheckbox( // Utilisation du nouveau widget
label: 'Consentement photo',
value: childData.photoConsent,
onChanged: onTogglePhotoConsent,
),
const SizedBox(height: 10),
AppCustomCheckbox( // Utilisation du nouveau widget
label: 'Naissance multiple',
value: childData.multipleBirth,
onChanged: onToggleMultipleBirth,
),
],
), ),
], ],
), ),
], if (canBeRemoved) // Afficher le bouton de suppression conditionnellement
), Positioned(
); top: -5, // Ajuster pour le positionnement visuel
} right: -5, // Ajuster pour le positionnement visuel
child: InkWell(
// Widget pour construire une checkbox personnalisée onTap: onRemove,
Widget _buildCustomCheckbox({required String label, required bool value, required ValueChanged<bool> onChanged}) { customBorder: const CircleBorder(), // Pour un effet de clic circulaire
const double checkboxSize = 20.0; child: Container(
const double checkmarkSizeFactor = 1.4; // Augmenté pour une coche plus grande padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
return GestureDetector( color: Colors.red.withOpacity(0.8), // Fond rouge pour le bouton X
onTap: () => onChanged(!value), shape: BoxShape.circle,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox( // Envelopper le Stack dans un SizedBox pour fixer sa taille
width: checkboxSize,
height: checkboxSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Image.asset(
'assets/images/square.png',
height: checkboxSize, // Taille fixe
width: checkboxSize, // Taille fixe
),
if (value)
Image.asset(
'assets/images/coche.png',
height: checkboxSize * checkmarkSizeFactor,
width: checkboxSize * checkmarkSizeFactor,
), ),
], child: const Icon(Icons.close, color: Colors.white, size: 18),
),
),
), ),
),
const SizedBox(width: 10),
Text(label, style: GoogleFonts.merienda(fontSize: 14)),
], ],
), ),
); );
} }
// Widget pour construire les champs de texte (peut être externalisé)
// Ajout de onTap et suffixIcon pour le DatePicker
Widget _buildTextField(
TextEditingController controller,
String label, {
TextInputType? keyboardType,
bool obscureText = false,
String? hintText,
bool enabled = true,
bool readOnly = false,
VoidCallback? onTap,
IconData? suffixIcon,
bool isRequired = true, // Nouveau paramètre, par défaut à true
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 4),
Container(
height: 45, // Hauteur fixe pour correspondre à l'image de fond
decoration: const BoxDecoration(
image: DecorationImage( // Rétablir input_field_bg.png
image: AssetImage('assets/images/input_field_bg.png'),
fit: BoxFit.fill,
),
// Pas de borderRadius ici si l'image de fond les a déjà
),
child: TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
enabled: enabled,
readOnly: readOnly,
onTap: onTap,
style: GoogleFonts.merienda(fontSize: 15, color: enabled ? Colors.black87 : Colors.grey),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 12), // Augmentation du padding vertical
hintText: hintText,
hintStyle: GoogleFonts.merienda(fontSize: 15, color: Colors.black38),
suffixIcon: suffixIcon != null ? Padding(
padding: const EdgeInsets.only(right: 8.0), // Espace pour l'icône
child: Icon(suffixIcon, color: Colors.black54, size: 20),
) : null,
isDense: true, // Aide à réduire la hauteur par défaut
),
validator: (value) {
if (!enabled) return null;
if (readOnly) return null;
if (isRequired && (value == null || value.isEmpty)) { // Validation conditionnée par isRequired
return 'Ce champ est obligatoire';
}
// TODO: Validations spécifiques (à garder si pertinent pour d'autres champs)
return null;
},
),
),
],
);
}
} }

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppCustomCheckbox extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
final double checkboxSize;
final double checkmarkSizeFactor;
const AppCustomCheckbox({
super.key,
required this.label,
required this.value,
required this.onChanged,
this.checkboxSize = 20.0,
this.checkmarkSizeFactor = 1.4,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value), // Inverse la valeur au clic
behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: checkboxSize,
height: checkboxSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Image.asset(
'assets/images/square.png',
height: checkboxSize,
width: checkboxSize,
),
if (value)
Image.asset(
'assets/images/coche.png',
height: checkboxSize * checkmarkSizeFactor,
width: checkboxSize * checkmarkSizeFactor,
),
],
),
),
const SizedBox(width: 10),
// Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long
Flexible(
child: Text(
label,
style: GoogleFonts.merienda(fontSize: 14),
overflow: TextOverflow.ellipsis, // Gérer le texte long
),
),
],
),
);
}
}

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class CustomAppTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final TextInputType? keyboardType;
final bool obscureText;
final bool enabled;
final bool readOnly;
final VoidCallback? onTap;
final IconData? suffixIcon;
final bool isRequired;
final String? Function(String?)? validator; // Permettre un validateur personnalisé
const CustomAppTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.keyboardType,
this.obscureText = false,
this.enabled = true,
this.readOnly = false,
this.onTap,
this.suffixIcon,
this.isRequired = true,
this.validator,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 4),
Container(
height: 45,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/input_field_bg.png'),
fit: BoxFit.fill,
),
),
child: TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
enabled: enabled,
readOnly: readOnly,
onTap: onTap,
style: GoogleFonts.merienda(fontSize: 15, color: enabled ? Colors.black87 : Colors.grey),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: const EdgeInsets.fromLTRB(15, 13, 15, 11),
hintText: hintText,
hintStyle: GoogleFonts.merienda(fontSize: 15, color: Colors.black38),
suffixIcon: suffixIcon != null ? Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(suffixIcon, color: Colors.black54, size: 20),
) : null,
isDense: true,
),
validator: validator ?? // Utilise le validateur fourni, ou celui par défaut
(value) {
if (!enabled) return null;
if (readOnly) return null;
if (isRequired && (value == null || value.isEmpty)) {
return 'Ce champ est obligatoire';
}
return null;
},
),
),
],
);
}
}

View File

@ -22,6 +22,7 @@
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="P'titsPas"> <meta name="apple-mobile-web-app-title" content="P'titsPas">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
@ -40,7 +41,7 @@
<script> <script>
// The value below is injected by flutter build, do not touch. // The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null; const serviceWorkerVersion = "{{flutter_service_worker_version}}";
</script> </script>
<!-- This script adds the flutter initialization JS code --> <!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script> <script src="flutter.js" defer></script>