diff --git a/frontend/assets/images/photo_frame.png b/frontend/assets/images/photo_frame.png new file mode 100644 index 0000000..9d9beeb Binary files /dev/null and b/frontend/assets/images/photo_frame.png differ diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 6675566..ef5a692 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -28,6 +28,8 @@ class ParentData { } class ChildData { + static const Object _unsetImage = Object(); + String firstName; String lastName; String dob; // Date de naissance ou prévisionnelle @@ -59,7 +61,7 @@ class ChildData { bool? photoConsent, bool? multipleBirth, bool? isUnbornChild, - File? imageFile, + Object? imageFile = _unsetImage, CardColorVertical? cardColor, }) { return ChildData( @@ -70,7 +72,7 @@ class ChildData { photoConsent: photoConsent ?? this.photoConsent, multipleBirth: multipleBirth ?? this.multipleBirth, isUnbornChild: isUnbornChild ?? this.isUnbornChild, - imageFile: imageFile ?? this.imageFile, + imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, cardColor: cardColor ?? this.cardColor, ); } diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index df6d8ed..4e0cb07 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -11,6 +11,7 @@ import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; class ParentRegisterStep3Screen extends StatefulWidget { // final UserRegistrationData registrationData; // Supprimé @@ -74,6 +75,22 @@ class _ParentRegisterStep3ScreenState extends State { super.dispose(); } + /// Même logique que nom / prénom parent : normalisation avant passage à l’étape suivante. + void _normalizeChildrenNamesAndGoToStep4( + BuildContext context, + UserRegistrationData registrationData, + ) { + for (var i = 0; i < registrationData.children.length; i++) { + final c = registrationData.children[i]; + final fn = formatPersonNameCase(c.firstName); + final ln = formatPersonNameCase(c.lastName); + if (fn != c.firstName || ln != c.lastName) { + registrationData.updateChild(i, c.copyWith(firstName: fn, lastName: ln)); + } + } + context.go('/parent-register-step4'); + } + void _scrollListener() { if (!_scrollController.hasClients) return; final position = _scrollController.position; @@ -272,6 +289,10 @@ class _ParentRegisterStep3ScreenState extends State { childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(imageFile: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { final c = registrationData.children[index]; @@ -281,17 +302,24 @@ class _ParentRegisterStep3ScreenState extends State { final c = registrationData.children[index]; registrationData.updateChild(index, c.copyWith(lastName: value)); }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, oldChild.copyWith(multipleBirth: newValue)); - }, onToggleIsUnborn: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, oldChild.copyWith(isUnbornChild: newValue)); + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, @@ -314,7 +342,7 @@ class _ParentRegisterStep3ScreenState extends State { const SizedBox(height: 30), // Boutons navigation en bas du scroll - _buildMobileButtons(context, config, screenSize), + _buildMobileButtons(context, config, screenSize, registrationData), const SizedBox(height: 20), ], ), @@ -368,6 +396,10 @@ class _ParentRegisterStep3ScreenState extends State { childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(imageFile: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { final c = registrationData.children[index]; @@ -377,17 +409,24 @@ class _ParentRegisterStep3ScreenState extends State { final c = registrationData.children[index]; registrationData.updateChild(index, c.copyWith(lastName: value)); }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, oldChild.copyWith(multipleBirth: newValue)); - }, onToggleIsUnborn: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, oldChild.copyWith(isUnbornChild: newValue)); + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, @@ -416,7 +455,12 @@ class _ParentRegisterStep3ScreenState extends State { } /// Boutons navigation mobile - Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) { + Widget _buildMobileButtons( + BuildContext context, + DisplayConfig config, + Size screenSize, + UserRegistrationData registrationData, + ) { return Row( children: [ Expanded( @@ -444,7 +488,7 @@ class _ParentRegisterStep3ScreenState extends State { text: 'Suivant', style: NavigationButtonStyle.green, onPressed: () { - context.go('/parent-register-step4'); + _normalizeChildrenNamesAndGoToStep4(context, registrationData); }, width: double.infinity, height: 50, diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 62dc5d3..883f9ee 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -248,11 +248,12 @@ class _ParentRegisterStep5ScreenState extends State { childIndex: index, mode: DisplayMode.readonly, onPickImage: () {}, + onClearImage: () {}, onDateSelect: () {}, onFirstNameChanged: (v) {}, onLastNameChanged: (v) {}, + onGenreChanged: (v) {}, onTogglePhotoConsent: (v) {}, - onToggleMultipleBirth: (v) {}, onToggleIsUnborn: (v) {}, onRemove: () {}, canBeRemoved: false, diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart new file mode 100644 index 0000000..feddec5 --- /dev/null +++ b/frontend/lib/utils/name_format_utils.dart @@ -0,0 +1,32 @@ +/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). + +String formatPersonNameCase(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + final words = trimmed.split(RegExp(r'\s+')); + return words.map(_capitalizeComposedWord).join(' '); +} + +String _capitalizeComposedWord(String word) { + if (word.isEmpty) { + return word; + } + final lower = word.toLowerCase(); + const separators = {'-', "'", '’'}; + final buffer = StringBuffer(); + var capitalizeNext = true; + + for (var i = 0; i < lower.length; i++) { + final char = lower[i]; + if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) { + buffer.write(char.toUpperCase()); + capitalizeNext = false; + } else { + buffer.write(char); + capitalizeNext = separators.contains(char); + } + } + return buffer.toString(); +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 39b6904..3870a2d 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -59,12 +59,17 @@ class ParentRegistrationPayload { for (var i = 0; i < d.children.length; i++) { final c = d.children[i]; final label = 'Enfant ${i + 1}'; - // Pas de saisie genre sur la carte (widget revenu à b18d5c8) : valeur par défaut côté JSON. + if (!c.photoConsent) { + return '$label : le consentement photo est obligatoire.'; + } if (c.isUnbornChild) { final due = _ddMmYyyyToIso(c.dob); if (due == null) { return '$label : indiquez une date de naissance prévisionnelle.'; } + if (!apiGenres.contains(c.genre)) { + return '$label : indiquez Fille, Garçon ou Inconnu.'; + } } else { if (c.firstName.trim().length < 2) { return '$label : le prénom doit contenir au moins 2 caractères.'; @@ -73,6 +78,9 @@ class ParentRegistrationPayload { if (birth == null) { return '$label : indiquez une date de naissance valide.'; } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez Fille ou Garçon.'; + } } } @@ -134,7 +142,7 @@ class ParentRegistrationPayload { static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', - 'grossesse_multiple': c.multipleBirth, + 'grossesse_multiple': false, }; final prenom = c.firstName.trim(); diff --git a/frontend/lib/widgets/app_custom_checkbox.dart b/frontend/lib/widgets/app_custom_checkbox.dart index a52640c..5331ab0 100644 --- a/frontend/lib/widgets/app_custom_checkbox.dart +++ b/frontend/lib/widgets/app_custom_checkbox.dart @@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget { final double checkboxSize; final double checkmarkSizeFactor; final double fontSize; + /// Survol (desktop) ou appui long (mobile) pour afficher un texte d’aide. + final String? tooltip; const AppCustomCheckbox({ super.key, @@ -17,48 +19,67 @@ class AppCustomCheckbox extends StatelessWidget { this.checkboxSize = 20.0, this.checkmarkSizeFactor = 1.4, this.fontSize = 16.0, + this.tooltip, }); @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, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onChanged(!value), + behavior: HitTestBehavior.opaque, + 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, + ), + ], ), - if (value) - Image.asset( - 'assets/images/coche.png', - height: checkboxSize * checkmarkSizeFactor, - width: checkboxSize * checkmarkSizeFactor, - ), - ], - ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - 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: fontSize), - overflow: TextOverflow.ellipsis, // Gérer le texte long + ), + if (tooltip != null && tooltip!.trim().isNotEmpty) ...[ + const SizedBox(width: 6), + Tooltip( + message: tooltip!.trim(), + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: fontSize * 1.2, + color: Colors.black54, ), ), ], - ), + ], ); } } \ No newline at end of file diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 18bdf72..220e3f8 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'dart:io' show File; @@ -6,9 +8,17 @@ 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'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +const String _photoConsentTooltip = + 'Obligatoire : cochez cette case pour autoriser l’utilisation de la photo de l’enfant.\n' + 'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n' + 'Dans le respect de la politique de confidentialité.'; + +/// Cadre photo (centre transparent), même dossier que `photo.png`. +const String _photoSketchFrameAsset = 'assets/images/photo_frame.png'; /// Widget pour afficher et éditer une carte enfant /// Utilisé dans le workflow d'inscription des parents @@ -16,11 +26,14 @@ class ChildCardWidget extends StatefulWidget { final ChildData childData; final int childIndex; final VoidCallback onPickImage; + /// Retire la photo sélectionnée (placeholder à la place). + final VoidCallback onClearImage; final VoidCallback onDateSelect; final ValueChanged onFirstNameChanged; final ValueChanged onLastNameChanged; + /// `H`, `F` ou `Autre` (API). « Inconnu » à l’UI = `Autre`. + final ValueChanged onGenreChanged; final ValueChanged onTogglePhotoConsent; - final ValueChanged onToggleMultipleBirth; final ValueChanged onToggleIsUnborn; final VoidCallback onRemove; final bool canBeRemoved; @@ -32,11 +45,12 @@ class ChildCardWidget extends StatefulWidget { required this.childData, required this.childIndex, required this.onPickImage, + required this.onClearImage, required this.onDateSelect, required this.onFirstNameChanged, required this.onLastNameChanged, + required this.onGenreChanged, required this.onTogglePhotoConsent, - required this.onToggleMultipleBirth, required this.onToggleIsUnborn, required this.onRemove, required this.canBeRemoved, @@ -49,10 +63,24 @@ class ChildCardWidget extends StatefulWidget { } class _ChildCardWidgetState extends State { + /// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG → portrait ~1:2 ; même idée qu’avant #78 : 345×1,1 pour 200 px de côté). + static double _desktopEditableCardWidth({ + required double photoSide, + required double maxWidth, + required double scaleFactor, + }) { + final fromPhoto = photoSide * (345.0 * 1.1 / 200.0); + final minForFields = 300.0 + 44.0 * scaleFactor; + return math.min(maxWidth, math.max(minForFields, fromPhoto)); + } + late TextEditingController _firstNameController; late TextEditingController _lastNameController; late TextEditingController _dobController; + FocusNode? _firstNameFocus; + FocusNode? _lastNameFocus; + @override void initState() { super.initState(); @@ -65,6 +93,38 @@ class _ChildCardWidgetState extends State { _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 + + if (widget.mode == DisplayMode.editable) { + _firstNameFocus = FocusNode(); + _lastNameFocus = FocusNode(); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _lastNameFocus!.addListener(_onLastNameFocusChange); + } + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override @@ -85,6 +145,10 @@ class _ChildCardWidgetState extends State { @override void dispose() { + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.dispose(); + _lastNameFocus?.dispose(); _firstNameController.dispose(); _lastNameController.dispose(); _dobController.dispose(); @@ -115,11 +179,22 @@ class _ChildCardWidgetState extends State { 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); + final Color initialPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow); + final Color hoverPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.32), baseCardColorForShadow); + + final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); + final double editableCardWidth = config.isMobile + ? double.infinity + : _desktopEditableCardWidth( + photoSide: photoSide, + maxWidth: screenSize.width * 0.92, + scaleFactor: scaleFactor, + ); return Container( - width: config.isMobile ? double.infinity : screenSize.width * 0.6, + width: editableCardWidth, // 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), @@ -132,24 +207,20 @@ class _ChildCardWidgetState extends State { 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), - ), - ), - ), + _buildEditablePhotoArea( + context: context, + config: config, + scaleFactor: scaleFactor, + photoSide: photoSide, + currentChildImage: currentChildImage, + initialPhotoShadow: initialPhotoShadow, + hoverPhotoShadow: hoverPhotoShadow, + ), + SizedBox(height: 8.0 * scaleFactor), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: config.isMobile ? 13.0 : 16.0, ), SizedBox(height: 10.0 * scaleFactor), Row( @@ -173,13 +244,16 @@ class _ChildCardWidgetState extends State { ], ), SizedBox(height: 8.0 * scaleFactor), + _buildGenreSegment(context, config, scaleFactor), + SizedBox(height: 8.0 * scaleFactor), _buildField( config: config, scaleFactor: scaleFactor, label: 'Prénom', controller: _firstNameController, - hint: 'Facultatif si à naître', + hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom', isRequired: !widget.childData.isUnbornChild, + focusNode: _firstNameFocus, ), SizedBox(height: 5.0 * scaleFactor), _buildField( @@ -188,6 +262,7 @@ class _ChildCardWidgetState extends State { label: 'Nom', controller: _lastNameController, hint: 'Nom de l\'enfant', + focusNode: _lastNameFocus, ), SizedBox(height: 8.0 * scaleFactor), _buildField( @@ -200,27 +275,6 @@ class _ChildCardWidgetState extends State { 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) @@ -230,7 +284,7 @@ class _ChildCardWidgetState extends State { onTap: widget.onRemove, customBorder: const CircleBorder(), child: Image.asset( - 'assets/images/red_cross2.png', + 'assets/images/cross.png', width: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36, fit: BoxFit.contain, @@ -252,6 +306,104 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildEditablePhotoArea({ + required BuildContext context, + required DisplayConfig config, + required double scaleFactor, + required double photoSide, + required File? currentChildImage, + required Color initialPhotoShadow, + required Color hoverPhotoShadow, + }) { + final canInteract = !config.isReadonly; + final outerRadius = BorderRadius.circular(10 * scaleFactor); + // Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor). + final photoClipRadius = BorderRadius.circular(photoSide * 0.14); + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + HoverReliefWidget( + onPressed: canInteract ? widget.onPickImage : null, + borderRadius: outerRadius, + initialElevation: 10, + hoverElevation: 16, + initialShadowColor: initialPhotoShadow, + hoverShadowColor: hoverPhotoShadow, + // Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué). + clipBehavior: + currentChildImage != null ? Clip.none : Clip.antiAlias, + child: SizedBox( + width: photoSide, + height: photoSide, + child: currentChildImage == null + ? ClipRRect( + borderRadius: outerRadius, + child: Center( + child: Image.asset( + 'assets/images/photo.png', + fit: BoxFit.contain, + width: photoSide, + height: photoSide, + ), + ), + ) + : Stack( + fit: StackFit.expand, + children: [ + // Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle). + Positioned.fill( + child: ClipRRect( + borderRadius: photoClipRadius, + child: kIsWeb + ? Image.network( + currentChildImage.path, + fit: BoxFit.cover, + ) + : Image.file( + currentChildImage, + fit: BoxFit.cover, + ), + ), + ), + Positioned.fill( + child: Image.asset( + _photoSketchFrameAsset, + fit: BoxFit.fill, + errorBuilder: (context, error, stackTrace) => + const SizedBox.shrink(), + ), + ), + ], + ), + ), + ), + if (currentChildImage != null && canInteract) + Positioned( + top: 8 * scaleFactor, + right: 8 * scaleFactor, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onClearImage, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(4), + child: Image.asset( + 'assets/images/cross.png', + width: config.isMobile ? 26 : 30, + height: config.isMobile ? 26 : 30, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ], + ); + } + /// 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) @@ -310,32 +462,43 @@ class _ChildCardWidgetState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // PHOTO (1/3) + // PHOTO (1/3) + consentement sous la photo 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: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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), + ), + ), ), - 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(height: 12), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 16.0, ), - ), + ], ), ), ), @@ -356,35 +519,14 @@ class _ChildCardWidgetState extends State { widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', _dobController.text ), + const SizedBox(height: 12), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), ], ), ), - 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, - ), - ], - ), ], ), ), @@ -460,6 +602,12 @@ class _ChildCardWidgetState extends State { ), ), ), + const SizedBox(height: 10), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 14.0, + ), const SizedBox(height: 16), // Champs @@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State { 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, - ), - ], - ), - ], - ), + const SizedBox(height: 8), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), @@ -525,6 +644,111 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildPhotoConsentRow({ + required BuildContext context, + required DisplayConfig config, + required double labelFontSize, + }) { + final readonly = config.isReadonly; + final primary = Theme.of(context).colorScheme.primary; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: widget.childData.photoConsent, + onChanged: readonly + ? null + : (v) { + if (v != null) widget.onTogglePhotoConsent(v); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + activeColor: primary, + ), + Flexible( + child: Text( + 'Consentement photo', + style: GoogleFonts.merienda(fontSize: labelFontSize), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Tooltip( + message: _photoConsentTooltip, + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: labelFontSize * 1.2, + color: Colors.black54, + ), + ), + ], + ); + } + + static String _genreDisplayLabel(String genre) { + switch (genre) { + case 'F': + return 'Fille'; + case 'H': + return 'Garçon'; + case 'Autre': + return 'Inconnu'; + default: + return '—'; + } + } + + /// Fille / Garçon ; « Inconnu » (`Autre`) uniquement si enfant à naître. + Widget _buildGenreSegment(BuildContext context, DisplayConfig config, double scaleFactor) { + if (config.isReadonly) { + return const SizedBox.shrink(); + } + final selected = widget.childData.genre; + final isUnborn = widget.childData.isUnbornChild; + final fontSize = config.isMobile ? 13.0 : 14.0 * scaleFactor; + final padV = config.isMobile ? 6.0 : 8.0 * scaleFactor; + + Widget seg(String label, String api) { + final on = selected == api; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: OutlinedButton( + onPressed: () => widget.onGenreChanged(api), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: padV), + visualDensity: VisualDensity.compact, + backgroundColor: on ? Colors.black.withValues(alpha: 0.08) : Colors.transparent, + foregroundColor: Colors.black87, + side: BorderSide( + width: on ? 2 : 1, + color: on ? Theme.of(context).primaryColor : Colors.black38, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize, fontWeight: on ? FontWeight.w700 : FontWeight.w500), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + return Row( + children: [ + seg('Fille', 'F'), + seg('Garçon', 'H'), + if (isUnborn) seg('Inconnu', 'Autre'), + ], + ); + } + /// Helper pour champ Readonly style "Beige" Widget _buildReadonlyField(String label, String value) { return Column( @@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State { bool readOnly = false, VoidCallback? onTap, IconData? suffixIcon, + FocusNode? focusNode, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State { } else { return CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, isRequired: isRequired, diff --git a/frontend/lib/widgets/hover_relief_widget.dart b/frontend/lib/widgets/hover_relief_widget.dart index cee1f79..caa67e8 100644 --- a/frontend/lib/widgets/hover_relief_widget.dart +++ b/frontend/lib/widgets/hover_relief_widget.dart @@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget { final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol final Color initialShadowColor; // Nouveau paramètre final Color hoverShadowColor; // Nouveau paramètre + /// `Clip.none` : ne pas découper le child (ex. trou du cadre PNG par-dessus la photo). + final Clip clipBehavior; const HoverReliefWidget({ required this.child, @@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget { this.enableHoverEffect = true, // Par défaut, l'effet est activé this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15) this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3) + this.clipBehavior = Clip.antiAlias, super.key, }); @@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State { elevation: elevation, shadowColor: shadowColor, borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ), ); @@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State { elevation: widget.initialElevation, // Utilise l'élévation initiale shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ); } diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 7e19729..ac79dfd 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:go_router/go_router.dart'; import 'dart:math' as math; @@ -76,6 +77,19 @@ class PersonalInfoFormScreen extends StatefulWidget { } class _PersonalInfoFormScreenState extends State { + /// Ordre de tabulation explicite (étape 1 / parent 2, etc.) + static const double _focusSecondPersonToggle = 1; + static const double _focusSameAddressToggle = 2; + static const double _focusLastName = 10; + static const double _focusFirstName = 11; + static const double _focusPhone = 12; + static const double _focusEmail = 13; + static const double _focusAddress = 14; + static const double _focusPostal = 15; + static const double _focusCity = 16; + static const double _focusNavPrevious = 100; + static const double _focusNavNext = 101; + final _formKey = GlobalKey(); late TextEditingController _lastNameController; late TextEditingController _firstNameController; @@ -89,6 +103,10 @@ class _PersonalInfoFormScreenState extends State { bool _sameAddress = false; bool _fieldsEnabled = true; + FocusNode? _lastNameFocus; + FocusNode? _firstNameFocus; + FocusNode? _cityFocus; + @override void initState() { super.initState(); @@ -109,10 +127,57 @@ class _PersonalInfoFormScreenState extends State { _sameAddress = widget.initialSameAddress ?? false; _updateAddressFields(); } + + if (widget.mode == DisplayMode.editable) { + _lastNameFocus = FocusNode(); + _firstNameFocus = FocusNode(); + _cityFocus = FocusNode(); + _lastNameFocus!.addListener(_onLastNameFocusChange); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _cityFocus!.addListener(_onCityFocusChange); + } + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onCityFocusChange() { + if (_cityFocus == null || _cityFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_cityController); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override void dispose() { + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _cityFocus?.removeListener(_onCityFocusChange); + _lastNameFocus?.dispose(); + _firstNameFocus?.dispose(); + _cityFocus?.dispose(); _lastNameController.dispose(); _firstNameController.dispose(); _phoneController.dispose(); @@ -131,7 +196,35 @@ class _PersonalInfoFormScreenState extends State { } } + Widget _wrapScrollBodyIfEditable({ + required DisplayConfig config, + required Widget child, + }) { + if (!config.isEditable) return child; + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: child, + ); + } + + Widget _orderedFocus({ + required bool enabled, + required double order, + required Widget child, + }) { + if (!enabled) return child; + return FocusTraversalOrder( + order: NumericFocusOrder(order), + child: child, + ); + } + void _handleSubmit() { + if (widget.mode == DisplayMode.editable) { + _applyPersonNameFormat(_lastNameController); + _applyPersonNameFormat(_firstNameController); + _applyPersonNameFormat(_cityController); + } if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) { final data = PersonalInfoData( firstName: _firstNameController.text, @@ -169,75 +262,84 @@ class _PersonalInfoFormScreenState extends State { Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 40.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.stepText, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 13 : 16, - color: Colors.black54, - ), - ), - SizedBox(height: config.isMobile ? 6 : 10), - Text( - widget.title, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 18 : 24, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: config.isMobile ? 16 : 30), - _buildCard(context, config, screenSize), - - // Boutons mobile sous la carte (dans le scroll) - if (config.isMobile) ...[ - const SizedBox(height: 20), - Padding( - padding: EdgeInsets.symmetric( - horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté) - ), - child: Row( - children: [ - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Précédent', - style: NavigationButtonStyle.purple, - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(widget.previousRoute); - } - }, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - const SizedBox(width: 16), // Écart entre les boutons - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Suivant', - style: NavigationButtonStyle.green, - onPressed: _handleSubmit, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - ], + child: _wrapScrollBodyIfEditable( + config: config, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.stepText, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 13 : 16, + color: Colors.black54, ), ), - const SizedBox(height: 10), + SizedBox(height: config.isMobile ? 6 : 10), + Text( + widget.title, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 18 : 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: config.isMobile ? 16 : 30), + _buildCard(context, config, screenSize), + if (config.isMobile) ...[ + const SizedBox(height: 20), + Padding( + padding: EdgeInsets.symmetric( + horizontal: screenSize.width * 0.05, + ), + child: Row( + children: [ + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavPrevious, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Précédent', + style: NavigationButtonStyle.purple, + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(widget.previousRoute); + } + }, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavNext, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Suivant', + style: NavigationButtonStyle.green, + onPressed: _handleSubmit, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ], ], - ], + ), ), ), ), @@ -522,17 +624,21 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _hasSecondPerson, - onChanged: (value) { - setState(() { - _hasSecondPerson = value; - _fieldsEnabled = value; - }); - }, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSecondPersonToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _hasSecondPerson, + onChanged: (value) { + setState(() { + _hasSecondPerson = value; + _fieldsEnabled = value; + }); + }, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -558,17 +664,21 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _sameAddress, - onChanged: _fieldsEnabled ? (value) { - setState(() { - _sameAddress = value; - _updateAddressFields(); - }); - } : null, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSameAddressToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _sameAddress, + onChanged: _fieldsEnabled ? (value) { + setState(() { + _sameAddress = value; + _updateAddressFields(); + }); + } : null, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -606,6 +716,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), ), const SizedBox(width: 20), @@ -616,6 +728,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), ), ], @@ -634,6 +748,7 @@ class _PersonalInfoFormScreenState extends State { keyboardType: TextInputType.phone, enabled: _fieldsEnabled, inputFormatters: _phoneInputFormatters, + focusOrder: _focusPhone, ), ), const SizedBox(width: 20), @@ -645,6 +760,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, ), ), ], @@ -658,6 +774,7 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, ), SizedBox(height: verticalSpacing), @@ -673,6 +790,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Code postal', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, ), ), const SizedBox(width: 20), @@ -684,6 +802,8 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, ), ), ], @@ -852,6 +972,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), const SizedBox(height: 12), @@ -862,6 +984,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), const SizedBox(height: 12), @@ -874,6 +998,7 @@ class _PersonalInfoFormScreenState extends State { keyboardType: TextInputType.phone, enabled: _fieldsEnabled, inputFormatters: _phoneInputFormatters, + focusOrder: _focusPhone, ), const SizedBox(height: 12), @@ -885,6 +1010,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, ), const SizedBox(height: 12), @@ -895,6 +1021,7 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, ), const SizedBox(height: 12), @@ -906,6 +1033,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Code postal', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, ), const SizedBox(height: 12), @@ -916,6 +1044,8 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, ), ], ); @@ -930,6 +1060,8 @@ class _PersonalInfoFormScreenState extends State { TextInputType? keyboardType, bool enabled = true, List? inputFormatters, + double? focusOrder, + FocusNode? focusNode, }) { if (config.isReadonly) { // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) @@ -942,9 +1074,9 @@ class _PersonalInfoFormScreenState extends State { value: displayValue, ); } else { - // Mode éditable : style adapté mobile/desktop - return CustomAppTextField( + Widget field = CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, style: CustomAppTextFieldStyle.beige, @@ -956,6 +1088,13 @@ class _PersonalInfoFormScreenState extends State { enabled: enabled, inputFormatters: inputFormatters, ); + if (focusOrder != null) { + field = FocusTraversalOrder( + order: NumericFocusOrder(focusOrder), + child: field, + ); + } + return field; } }