fix(am): ordre Tab formulaire pro et chevrons accessibles

- 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
This commit is contained in:
MARTIN Julien 2026-04-13 14:09:21 +02:00
parent d550e57cb8
commit a9ce4a6756
3 changed files with 160 additions and 72 deletions

View File

@ -33,6 +33,8 @@ class CustomAppTextField extends StatefulWidget {
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
/// Souvent appelé par la touche « suivant » du clavier (flèche), [onFieldSubmitted] ne lest pas.
final VoidCallback? onEditingComplete;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool autocorrect; final bool autocorrect;
final bool enableSuggestions; final bool enableSuggestions;
@ -62,6 +64,7 @@ class CustomAppTextField extends StatefulWidget {
this.autofillHints, this.autofillHints,
this.textInputAction, this.textInputAction,
this.onFieldSubmitted, this.onFieldSubmitted,
this.onEditingComplete,
this.inputFormatters, this.inputFormatters,
this.autocorrect = true, this.autocorrect = true,
this.enableSuggestions = true, this.enableSuggestions = true,
@ -144,6 +147,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
autofillHints: widget.autofillHints, autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted, onFieldSubmitted: widget.onFieldSubmitted,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled, enabled: widget.enabled,
readOnly: widget.readOnly, readOnly: widget.readOnly,
onTap: widget.onTap, onTap: widget.onTap,

View File

@ -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. /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget { class NirTextField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
/// Si non null, impose lordre Tab parmi les descendants (ex. après « Pays de naissance »).
final double? focusTraversalOrder;
final String labelText; final String labelText;
final String hintText; final String hintText;
final String? Function(String?)? validator; final String? Function(String?)? validator;
@ -23,6 +26,8 @@ class NirTextField extends StatelessWidget {
const NirTextField({ const NirTextField({
super.key, super.key,
required this.controller, required this.controller,
this.focusNode,
this.focusTraversalOrder,
this.labelText = 'N° Sécurité Sociale (NIR)', this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '13 chiffres + clé', this.hintText = '13 chiffres + clé',
this.validator, this.validator,
@ -38,8 +43,9 @@ class NirTextField extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomAppTextField( final field = CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: labelText, labelText: labelText,
hintText: hintText, hintText: hintText,
fieldWidth: fieldWidth, fieldWidth: fieldWidth,
@ -54,5 +60,13 @@ class NirTextField extends StatelessWidget {
style: style, style: style,
labelFieldSpacing: labelFieldSpacing, labelFieldSpacing: labelFieldSpacing,
); );
final o = focusTraversalOrder;
if (o != null) {
return FocusTraversalOrder(
order: NumericFocusOrder(o),
child: field,
);
}
return field;
} }
} }

View File

@ -89,7 +89,20 @@ class ProfessionalInfoFormScreen extends StatefulWidget {
class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> { class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Ordre Tab explicite : sans cela, la zone photo (InkWell) peut sinsé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 _dateOfBirthController = TextEditingController();
final _birthCityController = TextEditingController(); final _birthCityController = TextEditingController();
final _birthCountryController = TextEditingController(); final _birthCountryController = TextEditingController();
@ -101,7 +114,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
FocusNode? _birthCityFocus; FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus; FocusNode? _birthCountryFocus;
FocusNode? _nirFocus;
DateTime? _selectedDate; DateTime? _selectedDate;
DateTime? _selectedAgreementDate; DateTime? _selectedAgreementDate;
String? _photoPathFramework; String? _photoPathFramework;
@ -148,6 +162,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
if (widget.mode == DisplayMode.editable) { if (widget.mode == DisplayMode.editable) {
_birthCityFocus = FocusNode(); _birthCityFocus = FocusNode();
_birthCountryFocus = FocusNode(); _birthCountryFocus = FocusNode();
_nirFocus = FocusNode();
_birthCityFocus!.addListener(_onBirthCityFocusChange); _birthCityFocus!.addListener(_onBirthCityFocusChange);
_birthCountryFocus!.addListener(_onBirthCountryFocusChange); _birthCountryFocus!.addListener(_onBirthCountryFocusChange);
} }
@ -178,6 +193,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_birthCountryFocus?.removeListener(_onBirthCountryFocusChange); _birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
_birthCityFocus?.dispose(); _birthCityFocus?.dispose();
_birthCountryFocus?.dispose(); _birthCountryFocus?.dispose();
_nirFocus?.dispose();
_dateOfBirthController.dispose(); _dateOfBirthController.dispose();
_birthCityController.dispose(); _birthCityController.dispose();
_birthCountryController.dispose(); _birthCountryController.dispose();
@ -337,82 +353,89 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
return _buildCard(context, config, screenSize); return _buildCard(context, config, screenSize);
} }
return Scaffold( return FocusTraversalGroup(
body: Stack( policy: OrderedTraversalPolicy(),
children: [ child: Scaffold(
Positioned.fill( body: Stack(
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat), children: [
), Positioned.fill(
Center( child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
child: SingleChildScrollView( ),
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0), Center(
child: Column( child: SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
children: [ child: Column(
Text( mainAxisAlignment: MainAxisAlignment.center,
widget.stepText, children: [
style: GoogleFonts.merienda( Text(
fontSize: config.isMobile ? 13 : 16, widget.stepText,
color: Colors.black54, style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
), ),
), SizedBox(height: config.isMobile ? 6 : 10),
SizedBox(height: config.isMobile ? 6 : 10), Text(
Text( widget.title,
widget.title, style: GoogleFonts.merienda(
style: GoogleFonts.merienda( fontSize: config.isMobile ? 18 : 24,
fontSize: config.isMobile ? 18 : 24, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: Colors.black87,
color: Colors.black87, ),
textAlign: TextAlign.center,
), ),
textAlign: TextAlign.center, SizedBox(height: config.isMobile ? 16 : 20),
), _buildCard(context, config, screenSize),
SizedBox(height: config.isMobile ? 16 : 20),
_buildCard(context, config, screenSize), // Boutons mobile sous la carte
if (config.isMobile) ...[
// Boutons mobile sous la carte const SizedBox(height: 20),
if (config.isMobile) ...[ _buildMobileButtons(context, config, screenSize),
const SizedBox(height: 20), const SizedBox(height: 10),
_buildMobileButtons(context, config, screenSize), ],
const SizedBox(height: 10),
], ],
], ),
), ),
), ),
), // Chevrons desktop : visuellement sur les côtés ; ordre Tab 1011 = après « Places disponibles ».
// Chevrons desktop uniquement
if (!config.isMobile) ...[ if (!config.isMobile) ...[
// Chevron Gauche (Retour)
Positioned( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
left: 40, left: 40,
child: IconButton( child: FocusTraversalOrder(
icon: Transform( order: const NumericFocusOrder(_kTabChevronPrev),
alignment: Alignment.center, child: IconButton(
transform: Matrix4.rotationY(math.pi), icon: Transform(
child: Image.asset('assets/images/chevron_right.png', height: 40), 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( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
right: 40, right: 40,
child: IconButton( child: FocusTraversalOrder(
icon: Image.asset('assets/images/chevron_right.png', height: 40), order: const NumericFocusOrder(_kTabChevronNext),
onPressed: _submitForm, child: IconButton(
tooltip: 'Suivant', icon: Image.asset('assets/images/chevron_right.png', height: 40),
onPressed: _submitForm,
tooltip: 'Suivant',
),
), ),
), ),
], ],
], ],
),
), ),
); );
} }
@ -766,6 +789,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
onTap: () => _selectDate(context), onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null, validator: (v) => _selectedDate == null ? 'Date requise' : null,
focusTraversalOrder: _kTabDateBirth,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
_buildField( _buildField(
@ -775,6 +799,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Ex. Ajaccio, Paris, Casablanca…', hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: _validateBirthCity, validator: _validateBirthCity,
focusNode: _birthCityFocus, focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
_buildField( _buildField(
@ -784,6 +809,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Ex. France, Maroc…', hint: 'Ex. France, Maroc…',
validator: _validateBirthCountry, validator: _validateBirthCountry,
focusNode: _birthCountryFocus, focusNode: _birthCountryFocus,
focusTraversalOrder: _kTabBirthCountry,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
onEditingComplete: () => _nirFocus?.requestFocus(),
), ),
], ],
), ),
@ -793,6 +822,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
NirTextField( NirTextField(
controller: _nirController, controller: _nirController,
focusNode: _nirFocus,
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity, fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0, fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 20.0, labelFontSize: config.isMobile ? 15.0 : 20.0,
@ -810,6 +841,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _agrementController, controller: _agrementController,
hint: 'Votre numéro d\'agrément', hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null, validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
focusTraversalOrder: _kTabAgrement,
), ),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
@ -824,6 +856,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
validator: (_) => validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null, _selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
focusTraversalOrder: _kTabAgreementDate,
), ),
), ),
], ],
@ -850,6 +883,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} }
return null; return null;
}, },
focusTraversalOrder: _kTabCapacity,
), ),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
@ -861,6 +895,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Entre 0 et la capacité', hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: _validatePlacesAvailable, validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
), ),
), ),
], ],
@ -887,6 +922,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
onTap: () => _selectDate(context), onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null, validator: (v) => _selectedDate == null ? 'Date requise' : null,
focusTraversalOrder: _kTabDateBirth,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -897,6 +933,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Ex. Ajaccio, Paris, Casablanca…', hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: _validateBirthCity, validator: _validateBirthCity,
focusNode: _birthCityFocus, focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -907,11 +944,17 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Ex. France, Maroc…', hint: 'Ex. France, Maroc…',
validator: _validateBirthCountry, validator: _validateBirthCountry,
focusNode: _birthCountryFocus, focusNode: _birthCountryFocus,
focusTraversalOrder: _kTabBirthCountry,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
onEditingComplete: () => _nirFocus?.requestFocus(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
NirTextField( NirTextField(
controller: _nirController, controller: _nirController,
focusNode: _nirFocus,
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity, fieldWidth: double.infinity,
fieldHeight: 45.0, fieldHeight: 45.0,
labelFontSize: 15.0, labelFontSize: 15.0,
@ -925,6 +968,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _agrementController, controller: _agrementController,
hint: 'Votre numéro d\'agrément', hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null, validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
focusTraversalOrder: _kTabAgrement,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildField( _buildField(
@ -937,6 +981,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
validator: (_) => validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null, _selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
focusTraversalOrder: _kTabAgreementDate,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildField( _buildField(
@ -956,6 +1001,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} }
return null; return null;
}, },
focusTraversalOrder: _kTabCapacity,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildField( _buildField(
@ -965,6 +1011,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Entre 0 et la capacité', hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: _validatePlacesAvailable, validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
), ),
], ],
); );
@ -975,20 +1022,28 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final photoSize = config.isMobile ? 200.0 : 270.0; final photoSize = config.isMobile ? 200.0 : 270.0;
final scaleFactor = config.isMobile ? 0.9 : 1.1; 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( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
RegistrationPhotoSlot( if (config.isReadonly)
side: photoSize, slot
scaleFactor: scaleFactor, else
imageBytes: _photoBytes, FocusTraversalOrder(
imageFile: _photoFile, order: const NumericFocusOrder(_kTabPhotoPick),
imagePathOrAsset: _photoPathFramework, child: slot,
onTapPick: config.isReadonly ? null : _pickPhoto, ),
onClear: config.isReadonly ? null : _clearRegistrationPhoto,
baseShadowColor: Colors.green.shade300,
isMobile: config.isMobile,
),
SizedBox(height: config.isMobile ? 10.0 : 6.0), SizedBox(height: config.isMobile ? 10.0 : 6.0),
AppCustomCheckbox( AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.', label: 'J\'accepte l\'utilisation\nde ma photo.',
@ -1015,6 +1070,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
String? Function(String?)? validator, String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode, FocusNode? focusNode,
double? focusTraversalOrder,
TextInputAction? textInputAction,
ValueChanged<String>? onFieldSubmitted,
VoidCallback? onEditingComplete,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -1023,7 +1082,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
value: controller.text, value: controller.text,
); );
} else { } else {
return CustomAppTextField( final field = CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
labelText: label, labelText: label,
@ -1039,7 +1098,18 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
validator: validator, validator: validator,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
onEditingComplete: onEditingComplete,
); );
final o = focusTraversalOrder;
if (o != null) {
return FocusTraversalOrder(
order: NumericFocusOrder(o),
child: field,
);
}
return field;
} }
} }