diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index e1fb315..8ebdfca 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -6,9 +6,40 @@ import 'package:image_picker/image_picker.dart'; // import 'package:image_cropper/image_cropper.dart'; // Supprimé import 'dart:io' show File, Platform; // Ajout de Platform 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 -// class ChildData { ... } +// Classe de données pour un enfant +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 { const ParentRegisterStep3Screen({super.key}); @@ -22,60 +53,127 @@ class _ParentRegisterStep3ScreenState extends State { // List _children = [ChildData()]; // Commencer avec un enfant final _formKey = GlobalKey(); // Une clé par enfant sera nécessaire si validation complexe - // Contrôleurs pour le premier enfant (pour l'instant) - final _firstNameController = TextEditingController(); - final _lastNameController = TextEditingController(); - final _dobController = TextEditingController(); - bool _photoConsent = false; - bool _multipleBirth = false; - bool _isUnbornChild = false; // Nouvelle variable d'état - // TODO: Ajouter variable pour stocker l'image sélectionnée (par enfant) - // File? _childImage; + // Liste pour stocker les données de chaque enfant + List<_ChildFormData> _childrenDataList = []; + final ScrollController _scrollController = ScrollController(); // Ajout du ScrollController + bool _isScrollable = false; + bool _showLeftFade = false; + bool _showRightFade = false; + static const double _fadeExtent = 0.05; // Pourcentage de la vue pour le fondu (5%) - // File? _childImage; // Déjà présent et commenté - // Liste pour stocker les images des enfants (si gestion multi-enfants) - List _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 void 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 _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 _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 _selectDate(BuildContext context, int childIndex) async { + final _ChildFormData currentChild = _childrenDataList[childIndex]; final DateTime now = DateTime.now(); DateTime initialDatePickerDate = now; DateTime firstDatePickerDate = DateTime(1980); DateTime lastDatePickerDate = now; - if (_isUnbornChild) { - firstDatePickerDate = now; // Ne peut pas être avant aujourd'hui si à naître - lastDatePickerDate = now.add(const Duration(days: 300)); // Environ 10 mois dans le futur - // Si une date de naissance avait été entrée, on la garde pour initialDate si elle est dans la nouvelle plage - if (_dobController.text.isNotEmpty) { + if (currentChild.isUnbornChild) { + firstDatePickerDate = now; + lastDatePickerDate = now.add(const Duration(days: 300)); + if (currentChild.dobController.text.isNotEmpty) { try { - // Tenter de parser la date existante - List parts = _dobController.text.split('/'); + List parts = currentChild.dobController.text.split('/'); 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)) { initialDatePickerDate = parsedDate; } - } catch (e) { /* Ignorer si le format est incorrect */ } + } catch (e) { /* Ignorer */ } } } else { - // Si une date prévisionnelle avait été entrée, on la garde pour initialDate si elle est dans la nouvelle plage - if (_dobController.text.isNotEmpty) { + if (currentChild.dobController.text.isNotEmpty) { try { - List parts = _dobController.text.split('/'); + List parts = currentChild.dobController.text.split('/'); 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)) { initialDatePickerDate = parsedDate; @@ -93,135 +191,145 @@ class _ParentRegisterStep3ScreenState extends State { ); if (picked != null) { 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 - Future _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) { - // 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"); - } + void _removeChild(Key key) { + setState(() { + // Trouver et supprimer l'enfant par sa clé, et s'assurer qu'il en reste au moins un. + if (_childrenDataList.length > 1) { + _childrenDataList.removeWhere((child) => child.key == key); + } + }); + // S'assurer que le listener est appelé après la mise à jour de l'UI + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener()); } @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; - return Scaffold( body: Stack( children: [ - // Fond papier Positioned.fill( - child: Image.asset( - 'assets/images/paper2.png', - fit: BoxFit.cover, - repeat: ImageRepeat.repeat, - ), + child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat), ), - - // Contenu centré et scrollable Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 40.0), // Ajout de padding vertical + padding: const EdgeInsets.symmetric(vertical: 40.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Indicateur d'étape - Text( - 'Étape 3/X', // Mettre à jour le numéro d'étape total - style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54), - ), + Text('Étape 3/X', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)), const SizedBox(height: 10), - // Texte d'instruction Text( 'Merci de renseigner les informations de/vos enfant(s) :', - style: GoogleFonts.merienda( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), + style: GoogleFonts.merienda(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87), textAlign: TextAlign.center, ), 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 [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 - // Utilisation d'une Row pour placer côte à côte comme sur la maquette - // Il faudra peut-être ajuster pour les petits écrans (Wrap?) - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, // CHANGED: pour centrer verticalement - children: [ - // TODO: Remplacer par une ListView ou Column dynamique basée sur _children - // Pour l'instant, une seule carte - _buildChildCard(context, 0), // Index 0 pour le premier enfant - - const SizedBox(width: 30), - - HoverReliefWidget( - onPressed: () { - print("Ajouter un enfant via HoverReliefWidget"); - // setState(() { _children.add(ChildData()); }); + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + leftFade, // Bord gauche + Colors.black, // Devient opaque + Colors.black, // Reste opaque + rightFade // Bord droit + ], + stops: const [0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0], // 5% de fondu sur chaque bord + ).createShader(bounds); }, - borderRadius: BorderRadius.circular(15), - child: Image.asset( - 'assets/images/plus.png', - height: 80, - width: 80, + blendMode: BlendMode.dstIn, + child: Scrollbar( // Ajout du Scrollbar + controller: _scrollController, // Utiliser le même contrôleur + thumbVisibility: true, // Rendre la thumb toujours visible pour le web si souhaité, ou la laisser adaptative + 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 (identiques aux étapes précédentes) - // Chevron Gauche (Retour Step 2) + // Chevrons de navigation Positioned( top: screenSize.height / 2 - 20, left: 40, child: IconButton( - icon: Transform( - alignment: Alignment.center, - transform: Matrix4.rotationY(math.pi), - child: Image.asset('assets/images/chevron_right.png', height: 40), - ), - onPressed: () => Navigator.pop(context), // Retour étape 2 + icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)), + onPressed: () => Navigator.pop(context), tooltip: 'Retour', ), ), - - // Chevron Droit (Suivant Step 4) Positioned( top: screenSize.height / 2 - 20, right: 40, child: IconButton( icon: Image.asset('assets/images/chevron_right.png', height: 40), onPressed: () { - // TODO: Valider les infos enfants et Naviguer vers l'étape 4 print('Passer à l\'étape 4 (Situation familiale)'); // Navigator.pushNamed(context, '/parent-register/step4'); }, @@ -232,241 +340,138 @@ class _ParentRegisterStep3ScreenState extends State { ), ); } +} - // Widget pour construire UNE carte enfant - // L'index permettra de lier aux bons contrôleurs et données - Widget _buildChildCard(BuildContext context, int index) { - final File? currentChildImage = (index < _childImages.length) ? _childImages[index] : null; +// Nouveau Widget pour la carte enfant +class _ChildCardWidget extends StatelessWidget { + final _ChildFormData childData; + final int childIndex; // Utile pour certains callbacks ou logging + final VoidCallback onPickImage; + final VoidCallback onDateSelect; + final ValueChanged onTogglePhotoConsent; + final ValueChanged onToggleMultipleBirth; + final ValueChanged 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 - final Color baseLavandeColor = Colors.purple.shade200; // Placeholder pour la couleur de la carte lavande + const _ChildCardWidget({ + 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 hoverPhotoShadow = baseLavandeColor.withAlpha(130); + final Color hoverPhotoShadow = baseLavandeColor.withAlpha(130); return Container( width: 300, padding: const EdgeInsets.all(20), decoration: BoxDecoration( - image: const DecorationImage( - image: AssetImage('assets/images/card_lavander.png'), - fit: BoxFit.cover, - ), + image: const DecorationImage(image: AssetImage('assets/images/card_lavander.png'), fit: BoxFit.cover), borderRadius: BorderRadius.circular(20), ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Stack( // Stack pour pouvoir superposer le bouton de suppression 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( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - _buildCustomCheckbox( - label: 'Consentement photo', - value: _photoConsent, - onChanged: (newValue) { - setState(() => _photoConsent = newValue); - } + HoverReliefWidget( + onPressed: onPickImage, + borderRadius: BorderRadius.circular(10), + initialShadowColor: initialPhotoShadow, + 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), - _buildCustomCheckbox( - label: 'Naissance multiple', - value: _multipleBirth, - onChanged: (newValue) { - setState(() => _multipleBirth = newValue); - } + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Enfant à naître ?', style: GoogleFonts.merienda(fontSize: 14, fontWeight: FontWeight.w600)), + 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, + ), + ], ), ], ), - ], - ), - ); - } - - // Widget pour construire une checkbox personnalisée - Widget _buildCustomCheckbox({required String label, required bool value, required ValueChanged onChanged}) { - const double checkboxSize = 20.0; - const double checkmarkSizeFactor = 1.4; // Augmenté pour une coche plus grande - - return GestureDetector( - onTap: () => onChanged(!value), - 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, + 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( + onTap: onRemove, + customBorder: const CircleBorder(), // Pour un effet de clic circulaire + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.8), // Fond rouge pour le bouton X + shape: BoxShape.circle, ), - ], + 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; - }, - ), - ), - ], - ); - } } \ No newline at end of file diff --git a/frontend/lib/widgets/app_custom_checkbox.dart b/frontend/lib/widgets/app_custom_checkbox.dart new file mode 100644 index 0000000..63218a5 --- /dev/null +++ b/frontend/lib/widgets/app_custom_checkbox.dart @@ -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 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 + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart new file mode 100644 index 0000000..252a3fc --- /dev/null +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -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; + }, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/web/index.html b/frontend/web/index.html index 7a6630a..9b2a41d 100644 --- a/frontend/web/index.html +++ b/frontend/web/index.html @@ -22,6 +22,7 @@ + @@ -40,7 +41,7 @@