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 TextInputAction? textInputAction;
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 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<CustomAppTextField> {
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled,
readOnly: widget.readOnly,
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.
class NirTextField extends StatelessWidget {
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 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;
}
}

View File

@ -90,6 +90,19 @@ class ProfessionalInfoFormScreen extends StatefulWidget {
class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> {
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 _birthCityController = TextEditingController();
final _birthCountryController = TextEditingController();
@ -101,6 +114,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
FocusNode? _nirFocus;
DateTime? _selectedDate;
DateTime? _selectedAgreementDate;
@ -148,6 +162,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
_birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
_birthCityFocus?.dispose();
_birthCountryFocus?.dispose();
_nirFocus?.dispose();
_dateOfBirthController.dispose();
_birthCityController.dispose();
_birthCountryController.dispose();
@ -337,82 +353,89 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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),
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),
// 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 1011 = 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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: _validateBirthCity,
focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
),
SizedBox(height: verticalSpacing),
_buildField(
@ -784,6 +809,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
focusTraversalOrder: _kTabAgreementDate,
),
),
],
@ -850,6 +883,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
}
return null;
},
focusTraversalOrder: _kTabCapacity,
),
),
const SizedBox(width: 14),
@ -861,6 +895,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
),
),
],
@ -887,6 +922,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: _validateBirthCity,
focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
),
const SizedBox(height: 12),
@ -907,11 +944,17 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
}
return null;
},
focusTraversalOrder: _kTabCapacity,
),
const SizedBox(height: 12),
_buildField(
@ -965,6 +1011,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
),
],
);
@ -975,20 +1022,28 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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<ProfessionalInfoFormScreen>
String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
double? focusTraversalOrder,
TextInputAction? textInputAction,
ValueChanged<String>? onFieldSubmitted,
VoidCallback? onEditingComplete,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@ -1023,7 +1082,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
value: controller.text,
);
} else {
return CustomAppTextField(
final field = CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: label,
@ -1039,7 +1098,18 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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;
}
}