From a9ce4a6756199e91397bc431c66770b986812f56 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 13 Apr 2026 14:09:21 +0200 Subject: [PATCH] fix(am): ordre Tab formulaire pro et chevrons accessibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ordre de focus explicite (NumericFocusOrder) pour l’inscription AM étape 2 : chaîne des champs jusqu’aux chevrons, Pays → NIR, places → Suivant (10) puis Précédent (11). - FocusTraversalGroup avec OrderedTraversalPolicy sur le Scaffold pour respecter cet ordre (desktop / web). - Champ pays : TextInputAction.next, onFieldSubmitted et onEditingComplete vers le NIR ; CustomAppTextField expose onEditingComplete. - NirTextField : focusNode et focusTraversalOrder optionnels. Made-with: Cursor --- .../lib/widgets/custom_app_text_field.dart | 4 + frontend/lib/widgets/nir_text_field.dart | 16 +- .../professional_info_form_screen.dart | 212 ++++++++++++------ 3 files changed, 160 insertions(+), 72 deletions(-) diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 72d5b84..e8e01a9 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -33,6 +33,8 @@ class CustomAppTextField extends StatefulWidget { final Iterable? autofillHints; final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; + /// Souvent appelé par la touche « suivant » du clavier (flèche), là où [onFieldSubmitted] ne l’est pas. + final VoidCallback? onEditingComplete; final List? inputFormatters; final bool autocorrect; final bool enableSuggestions; @@ -62,6 +64,7 @@ class CustomAppTextField extends StatefulWidget { this.autofillHints, this.textInputAction, this.onFieldSubmitted, + this.onEditingComplete, this.inputFormatters, this.autocorrect = true, this.enableSuggestions = true, @@ -144,6 +147,7 @@ class _CustomAppTextFieldState extends State { autofillHints: widget.autofillHints, textInputAction: widget.textInputAction, onFieldSubmitted: widget.onFieldSubmitted, + onEditingComplete: widget.onEditingComplete, enabled: widget.enabled, readOnly: widget.readOnly, onTap: widget.onTap, diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 0e0f951..7ea3d94 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -8,6 +8,9 @@ import 'custom_app_text_field.dart'; /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission. class NirTextField extends StatelessWidget { final TextEditingController controller; + final FocusNode? focusNode; + /// Si non null, impose l’ordre Tab parmi les descendants (ex. après « Pays de naissance »). + final double? focusTraversalOrder; final String labelText; final String hintText; final String? Function(String?)? validator; @@ -23,6 +26,8 @@ class NirTextField extends StatelessWidget { const NirTextField({ super.key, required this.controller, + this.focusNode, + this.focusTraversalOrder, this.labelText = 'N° Sécurité Sociale (NIR)', this.hintText = '13 chiffres + clé', this.validator, @@ -38,8 +43,9 @@ class NirTextField extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomAppTextField( + final field = CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: labelText, hintText: hintText, fieldWidth: fieldWidth, @@ -54,5 +60,13 @@ class NirTextField extends StatelessWidget { style: style, labelFieldSpacing: labelFieldSpacing, ); + final o = focusTraversalOrder; + if (o != null) { + return FocusTraversalOrder( + order: NumericFocusOrder(o), + child: field, + ); + } + return field; } } diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 5a111cb..6225cd1 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -89,7 +89,20 @@ class ProfessionalInfoFormScreen extends StatefulWidget { class _ProfessionalInfoFormScreenState extends State { final _formKey = GlobalKey(); - + + /// Ordre Tab explicite : sans cela, la zone photo (InkWell) peut s’insérer entre pays et NIR. + static const double _kTabPhotoPick = 1; + static const double _kTabDateBirth = 2; + static const double _kTabBirthCity = 3; + static const double _kTabBirthCountry = 4; + static const double _kTabNir = 5; + static const double _kTabAgrement = 6; + static const double _kTabAgreementDate = 7; + static const double _kTabCapacity = 8; + static const double _kTabPlaces = 9; + static const double _kTabChevronNext = 10; + static const double _kTabChevronPrev = 11; + final _dateOfBirthController = TextEditingController(); final _birthCityController = TextEditingController(); final _birthCountryController = TextEditingController(); @@ -101,7 +114,8 @@ class _ProfessionalInfoFormScreenState extends State FocusNode? _birthCityFocus; FocusNode? _birthCountryFocus; - + FocusNode? _nirFocus; + DateTime? _selectedDate; DateTime? _selectedAgreementDate; String? _photoPathFramework; @@ -148,6 +162,7 @@ class _ProfessionalInfoFormScreenState extends State if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); _birthCountryFocus = FocusNode(); + _nirFocus = FocusNode(); _birthCityFocus!.addListener(_onBirthCityFocusChange); _birthCountryFocus!.addListener(_onBirthCountryFocusChange); } @@ -178,6 +193,7 @@ class _ProfessionalInfoFormScreenState extends State _birthCountryFocus?.removeListener(_onBirthCountryFocusChange); _birthCityFocus?.dispose(); _birthCountryFocus?.dispose(); + _nirFocus?.dispose(); _dateOfBirthController.dispose(); _birthCityController.dispose(); _birthCountryController.dispose(); @@ -337,82 +353,89 @@ class _ProfessionalInfoFormScreenState extends State return _buildCard(context, config, screenSize); } - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat), - ), - Center( - child: SingleChildScrollView( - padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.stepText, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 13 : 16, - color: Colors.black54, + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat), + ), + Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.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, + 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, ), - textAlign: TextAlign.center, - ), - SizedBox(height: config.isMobile ? 16 : 20), - _buildCard(context, config, screenSize), - - // Boutons mobile sous la carte - if (config.isMobile) ...[ - const SizedBox(height: 20), - _buildMobileButtons(context, config, screenSize), - const SizedBox(height: 10), + SizedBox(height: config.isMobile ? 16 : 20), + _buildCard(context, config, screenSize), + + // Boutons mobile sous la carte + if (config.isMobile) ...[ + const SizedBox(height: 20), + _buildMobileButtons(context, config, screenSize), + const SizedBox(height: 10), + ], ], - ], + ), ), ), - ), - // Chevrons desktop uniquement + // Chevrons desktop : visuellement sur les côtés ; ordre Tab 10–11 = après « Places disponibles ». if (!config.isMobile) ...[ - // Chevron Gauche (Retour) 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), + child: FocusTraversalOrder( + order: const NumericFocusOrder(_kTabChevronPrev), + child: IconButton( + icon: Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(math.pi), + child: Image.asset('assets/images/chevron_right.png', height: 40), + ), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(widget.previousRoute); + } + }, + tooltip: 'Précédent', ), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(widget.previousRoute); - } - }, - tooltip: 'Précédent', ), ), - // Chevron Droit (Suivant) Positioned( top: screenSize.height / 2 - 20, right: 40, - child: IconButton( - icon: Image.asset('assets/images/chevron_right.png', height: 40), - onPressed: _submitForm, - tooltip: 'Suivant', + child: FocusTraversalOrder( + order: const NumericFocusOrder(_kTabChevronNext), + child: IconButton( + icon: Image.asset('assets/images/chevron_right.png', height: 40), + onPressed: _submitForm, + tooltip: 'Suivant', + ), ), ), ], ], + ), ), ); } @@ -766,6 +789,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: () => _selectDate(context), suffixIcon: Icons.calendar_today, validator: (v) => _selectedDate == null ? 'Date requise' : null, + focusTraversalOrder: _kTabDateBirth, ), SizedBox(height: verticalSpacing), _buildField( @@ -775,6 +799,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. Ajaccio, Paris, Casablanca…', validator: _validateBirthCity, focusNode: _birthCityFocus, + focusTraversalOrder: _kTabBirthCity, ), SizedBox(height: verticalSpacing), _buildField( @@ -784,6 +809,10 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. France, Maroc…', validator: _validateBirthCountry, focusNode: _birthCountryFocus, + focusTraversalOrder: _kTabBirthCountry, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _nirFocus?.requestFocus(), + onEditingComplete: () => _nirFocus?.requestFocus(), ), ], ), @@ -793,6 +822,8 @@ class _ProfessionalInfoFormScreenState extends State SizedBox(height: verticalSpacing), NirTextField( controller: _nirController, + focusNode: _nirFocus, + focusTraversalOrder: _nirFocus != null ? _kTabNir : null, fieldWidth: double.infinity, fieldHeight: config.isMobile ? 45.0 : 53.0, labelFontSize: config.isMobile ? 15.0 : 20.0, @@ -810,6 +841,7 @@ class _ProfessionalInfoFormScreenState extends State controller: _agrementController, hint: 'Votre numéro d\'agrément', validator: (v) => v!.isEmpty ? 'Agrément requis' : null, + focusTraversalOrder: _kTabAgrement, ), ), const SizedBox(width: 14), @@ -824,6 +856,7 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: Icons.calendar_today, validator: (_) => _selectedAgreementDate == null ? 'Date d\'obtention requise' : null, + focusTraversalOrder: _kTabAgreementDate, ), ), ], @@ -850,6 +883,7 @@ class _ProfessionalInfoFormScreenState extends State } return null; }, + focusTraversalOrder: _kTabCapacity, ), ), const SizedBox(width: 14), @@ -861,6 +895,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Entre 0 et la capacité', keyboardType: TextInputType.number, validator: _validatePlacesAvailable, + focusTraversalOrder: _kTabPlaces, ), ), ], @@ -887,6 +922,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: () => _selectDate(context), suffixIcon: Icons.calendar_today, validator: (v) => _selectedDate == null ? 'Date requise' : null, + focusTraversalOrder: _kTabDateBirth, ), const SizedBox(height: 12), @@ -897,6 +933,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. Ajaccio, Paris, Casablanca…', validator: _validateBirthCity, focusNode: _birthCityFocus, + focusTraversalOrder: _kTabBirthCity, ), const SizedBox(height: 12), @@ -907,11 +944,17 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Ex. France, Maroc…', validator: _validateBirthCountry, focusNode: _birthCountryFocus, + focusTraversalOrder: _kTabBirthCountry, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _nirFocus?.requestFocus(), + onEditingComplete: () => _nirFocus?.requestFocus(), ), const SizedBox(height: 12), NirTextField( controller: _nirController, + focusNode: _nirFocus, + focusTraversalOrder: _nirFocus != null ? _kTabNir : null, fieldWidth: double.infinity, fieldHeight: 45.0, labelFontSize: 15.0, @@ -925,6 +968,7 @@ class _ProfessionalInfoFormScreenState extends State controller: _agrementController, hint: 'Votre numéro d\'agrément', validator: (v) => v!.isEmpty ? 'Agrément requis' : null, + focusTraversalOrder: _kTabAgrement, ), const SizedBox(height: 12), _buildField( @@ -937,6 +981,7 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: Icons.calendar_today, validator: (_) => _selectedAgreementDate == null ? 'Date d\'obtention requise' : null, + focusTraversalOrder: _kTabAgreementDate, ), const SizedBox(height: 12), _buildField( @@ -956,6 +1001,7 @@ class _ProfessionalInfoFormScreenState extends State } return null; }, + focusTraversalOrder: _kTabCapacity, ), const SizedBox(height: 12), _buildField( @@ -965,6 +1011,7 @@ class _ProfessionalInfoFormScreenState extends State hint: 'Entre 0 et la capacité', keyboardType: TextInputType.number, validator: _validatePlacesAvailable, + focusTraversalOrder: _kTabPlaces, ), ], ); @@ -975,20 +1022,28 @@ class _ProfessionalInfoFormScreenState extends State final photoSize = config.isMobile ? 200.0 : 270.0; final scaleFactor = config.isMobile ? 0.9 : 1.1; + final slot = RegistrationPhotoSlot( + side: photoSize, + scaleFactor: scaleFactor, + imageBytes: _photoBytes, + imageFile: _photoFile, + imagePathOrAsset: _photoPathFramework, + onTapPick: config.isReadonly ? null : _pickPhoto, + onClear: config.isReadonly ? null : _clearRegistrationPhoto, + baseShadowColor: Colors.green.shade300, + isMobile: config.isMobile, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - RegistrationPhotoSlot( - side: photoSize, - scaleFactor: scaleFactor, - imageBytes: _photoBytes, - imageFile: _photoFile, - imagePathOrAsset: _photoPathFramework, - onTapPick: config.isReadonly ? null : _pickPhoto, - onClear: config.isReadonly ? null : _clearRegistrationPhoto, - baseShadowColor: Colors.green.shade300, - isMobile: config.isMobile, - ), + if (config.isReadonly) + slot + else + FocusTraversalOrder( + order: const NumericFocusOrder(_kTabPhotoPick), + child: slot, + ), SizedBox(height: config.isMobile ? 10.0 : 6.0), AppCustomCheckbox( label: 'J\'accepte l\'utilisation\nde ma photo.', @@ -1015,6 +1070,10 @@ class _ProfessionalInfoFormScreenState extends State String? Function(String?)? validator, List? inputFormatters, FocusNode? focusNode, + double? focusTraversalOrder, + TextInputAction? textInputAction, + ValueChanged? onFieldSubmitted, + VoidCallback? onEditingComplete, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -1023,7 +1082,7 @@ class _ProfessionalInfoFormScreenState extends State value: controller.text, ); } else { - return CustomAppTextField( + final field = CustomAppTextField( controller: controller, focusNode: focusNode, labelText: label, @@ -1039,7 +1098,18 @@ class _ProfessionalInfoFormScreenState extends State suffixIcon: suffixIcon, validator: validator, inputFormatters: inputFormatters, + textInputAction: textInputAction, + onFieldSubmitted: onFieldSubmitted, + onEditingComplete: onEditingComplete, ); + final o = focusTraversalOrder; + if (o != null) { + return FocusTraversalOrder( + order: NumericFocusOrder(o), + child: field, + ); + } + return field; } }