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:
parent
d550e57cb8
commit
a9ce4a6756
@ -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), là où [onFieldSubmitted] ne l’est 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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 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,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 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user