feat(frontend): inscription — UI cartes enfant, formatage noms, focus Tab

- Formulaire infos perso : ordre de tabulation explicite, Checkbox Material
  pour le consentement photo, formatage nom/prénom/ville à la perte de focus
  et à la soumission (name_format_utils).
- Carte enfant : cadre photo, croix, ombres, consentement ; formatage
  prénom/nom enfant ; normalisation avant passage étape 4.
- Asset photo_frame.png ; HoverReliefWidget clipBehavior.
- Ajustements payload / modèle / étapes inscription liés au parcours.

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-03-28 20:40:27 +01:00
parent a723412043
commit 110240b682
10 changed files with 740 additions and 264 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@ -28,6 +28,8 @@ class ParentData {
} }
class ChildData { class ChildData {
static const Object _unsetImage = Object();
String firstName; String firstName;
String lastName; String lastName;
String dob; // Date de naissance ou prévisionnelle String dob; // Date de naissance ou prévisionnelle
@ -59,7 +61,7 @@ class ChildData {
bool? photoConsent, bool? photoConsent,
bool? multipleBirth, bool? multipleBirth,
bool? isUnbornChild, bool? isUnbornChild,
File? imageFile, Object? imageFile = _unsetImage,
CardColorVertical? cardColor, CardColorVertical? cardColor,
}) { }) {
return ChildData( return ChildData(
@ -70,7 +72,7 @@ class ChildData {
photoConsent: photoConsent ?? this.photoConsent, photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth, multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild, isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: imageFile ?? this.imageFile, imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
cardColor: cardColor ?? this.cardColor, cardColor: cardColor ?? this.cardColor,
); );
} }

View File

@ -11,6 +11,7 @@ import '../../models/card_assets.dart';
import '../../config/display_config.dart'; import '../../config/display_config.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
class ParentRegisterStep3Screen extends StatefulWidget { class ParentRegisterStep3Screen extends StatefulWidget {
// final UserRegistrationData registrationData; // Supprimé // final UserRegistrationData registrationData; // Supprimé
@ -74,6 +75,22 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
super.dispose(); 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() { void _scrollListener() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final position = _scrollController.position; final position = _scrollController.position;
@ -272,6 +289,10 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(imageFile: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() {
final c = registrationData.children[index]; final c = registrationData.children[index];
@ -281,17 +302,24 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final c = registrationData.children[index]; final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(lastName: value)); registrationData.updateChild(index, c.copyWith(lastName: value));
}), }),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
}, },
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(multipleBirth: newValue));
},
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; 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), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
@ -314,7 +342,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
const SizedBox(height: 30), const SizedBox(height: 30),
// Boutons navigation en bas du scroll // Boutons navigation en bas du scroll
_buildMobileButtons(context, config, screenSize), _buildMobileButtons(context, config, screenSize, registrationData),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@ -368,6 +396,10 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(imageFile: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() {
final c = registrationData.children[index]; final c = registrationData.children[index];
@ -377,17 +409,24 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final c = registrationData.children[index]; final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(lastName: value)); registrationData.updateChild(index, c.copyWith(lastName: value));
}), }),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
}, },
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(multipleBirth: newValue));
},
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; 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), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
@ -416,7 +455,12 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
} }
/// Boutons navigation mobile /// Boutons navigation mobile
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildMobileButtons(
BuildContext context,
DisplayConfig config,
Size screenSize,
UserRegistrationData registrationData,
) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
@ -444,7 +488,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
text: 'Suivant', text: 'Suivant',
style: NavigationButtonStyle.green, style: NavigationButtonStyle.green,
onPressed: () { onPressed: () {
context.go('/parent-register-step4'); _normalizeChildrenNamesAndGoToStep4(context, registrationData);
}, },
width: double.infinity, width: double.infinity,
height: 50, height: 50,

View File

@ -248,11 +248,12 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
childIndex: index, childIndex: index,
mode: DisplayMode.readonly, mode: DisplayMode.readonly,
onPickImage: () {}, onPickImage: () {},
onClearImage: () {},
onDateSelect: () {}, onDateSelect: () {},
onFirstNameChanged: (v) {}, onFirstNameChanged: (v) {},
onLastNameChanged: (v) {}, onLastNameChanged: (v) {},
onGenreChanged: (v) {},
onTogglePhotoConsent: (v) {}, onTogglePhotoConsent: (v) {},
onToggleMultipleBirth: (v) {},
onToggleIsUnborn: (v) {}, onToggleIsUnborn: (v) {},
onRemove: () {}, onRemove: () {},
canBeRemoved: false, canBeRemoved: false,

View File

@ -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 = <String>{'-', "'", ''};
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();
}

View File

@ -59,12 +59,17 @@ class ParentRegistrationPayload {
for (var i = 0; i < d.children.length; i++) { for (var i = 0; i < d.children.length; i++) {
final c = d.children[i]; final c = d.children[i];
final label = 'Enfant ${i + 1}'; 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) { if (c.isUnbornChild) {
final due = _ddMmYyyyToIso(c.dob); final due = _ddMmYyyyToIso(c.dob);
if (due == null) { if (due == null) {
return '$label : indiquez une date de naissance prévisionnelle.'; return '$label : indiquez une date de naissance prévisionnelle.';
} }
if (!apiGenres.contains(c.genre)) {
return '$label : indiquez Fille, Garçon ou Inconnu.';
}
} else { } else {
if (c.firstName.trim().length < 2) { if (c.firstName.trim().length < 2) {
return '$label : le prénom doit contenir au moins 2 caractères.'; return '$label : le prénom doit contenir au moins 2 caractères.';
@ -73,6 +78,9 @@ class ParentRegistrationPayload {
if (birth == null) { if (birth == null) {
return '$label : indiquez une date de naissance valide.'; 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<String, dynamic> _childToJson(ChildData c, int index, String parentNom) { static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth, 'grossesse_multiple': false,
}; };
final prenom = c.firstName.trim(); final prenom = c.firstName.trim();

View File

@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget {
final double checkboxSize; final double checkboxSize;
final double checkmarkSizeFactor; final double checkmarkSizeFactor;
final double fontSize; final double fontSize;
/// Survol (desktop) ou appui long (mobile) pour afficher un texte daide.
final String? tooltip;
const AppCustomCheckbox({ const AppCustomCheckbox({
super.key, super.key,
@ -17,13 +19,17 @@ class AppCustomCheckbox extends StatelessWidget {
this.checkboxSize = 20.0, this.checkboxSize = 20.0,
this.checkmarkSizeFactor = 1.4, this.checkmarkSizeFactor = 1.4,
this.fontSize = 16.0, this.fontSize = 16.0,
this.tooltip,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return Row(
onTap: () => onChanged(!value), // Inverse la valeur au clic mainAxisSize: MainAxisSize.min,
behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable children: [
GestureDetector(
onTap: () => onChanged(!value),
behavior: HitTestBehavior.opaque,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -49,16 +55,31 @@ class AppCustomCheckbox extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
// Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: GoogleFonts.merienda(fontSize: fontSize), style: GoogleFonts.merienda(fontSize: fontSize),
overflow: TextOverflow.ellipsis, // Gérer le texte long overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
), ),
),
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,
),
),
],
],
); );
} }
} }

View File

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'dart:io' show File; import 'dart:io' show File;
@ -6,9 +8,17 @@ import '../models/user_registration_data.dart';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart'; import 'hover_relief_widget.dart';
import '../config/display_config.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 lutilisation de la photo de lenfant.\n'
'Suivi du dossier et organisation de laccueil (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 /// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents /// Utilisé dans le workflow d'inscription des parents
@ -16,11 +26,14 @@ class ChildCardWidget extends StatefulWidget {
final ChildData childData; final ChildData childData;
final int childIndex; final int childIndex;
final VoidCallback onPickImage; final VoidCallback onPickImage;
/// Retire la photo sélectionnée (placeholder à la place).
final VoidCallback onClearImage;
final VoidCallback onDateSelect; final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged; final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged; final ValueChanged<String> onLastNameChanged;
/// `H`, `F` ou `Autre` (API). « Inconnu » à lUI = `Autre`.
final ValueChanged<String> onGenreChanged;
final ValueChanged<bool> onTogglePhotoConsent; final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn; final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove; final VoidCallback onRemove;
final bool canBeRemoved; final bool canBeRemoved;
@ -32,11 +45,12 @@ class ChildCardWidget extends StatefulWidget {
required this.childData, required this.childData,
required this.childIndex, required this.childIndex,
required this.onPickImage, required this.onPickImage,
required this.onClearImage,
required this.onDateSelect, required this.onDateSelect,
required this.onFirstNameChanged, required this.onFirstNameChanged,
required this.onLastNameChanged, required this.onLastNameChanged,
required this.onGenreChanged,
required this.onTogglePhotoConsent, required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn, required this.onToggleIsUnborn,
required this.onRemove, required this.onRemove,
required this.canBeRemoved, required this.canBeRemoved,
@ -49,10 +63,24 @@ class ChildCardWidget extends StatefulWidget {
} }
class _ChildCardWidgetState extends State<ChildCardWidget> { class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG portrait ~1:2 ; même idée quavant #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 _firstNameController;
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _dobController; late TextEditingController _dobController;
FocusNode? _firstNameFocus;
FocusNode? _lastNameFocus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -65,6 +93,38 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text)); _firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text)); _lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici // 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 @override
@ -85,6 +145,10 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
@override @override
void dispose() { void dispose() {
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.dispose();
_lastNameFocus?.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_dobController.dispose(); _dobController.dispose();
@ -115,11 +179,22 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200 ? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90); final Color initialPhotoShadow =
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); 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( 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 // On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
// height: config.isMobile ? null : 600.0 * scaleFactor, // height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor), padding: EdgeInsets.all(22.0 * scaleFactor),
@ -132,24 +207,20 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ... (contenu existant) _buildEditablePhotoArea(
HoverReliefWidget( context: context,
onPressed: config.isReadonly ? null : widget.onPickImage, config: config,
borderRadius: BorderRadius.circular(10), scaleFactor: scaleFactor,
initialShadowColor: initialPhotoShadow, photoSide: photoSide,
hoverShadowColor: hoverPhotoShadow, currentChildImage: currentChildImage,
child: SizedBox( initialPhotoShadow: initialPhotoShadow,
height: 200.0 * (config.isMobile ? 0.8 : 1.0), hoverPhotoShadow: hoverPhotoShadow,
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),
),
),
), ),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: config.isMobile ? 13.0 : 16.0,
), ),
SizedBox(height: 10.0 * scaleFactor), SizedBox(height: 10.0 * scaleFactor),
Row( Row(
@ -173,13 +244,16 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
], ],
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildGenreSegment(context, config, scaleFactor),
SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
config: config, config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
label: 'Prénom', label: 'Prénom',
controller: _firstNameController, controller: _firstNameController,
hint: 'Facultatif si à naître', hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom',
isRequired: !widget.childData.isUnbornChild, isRequired: !widget.childData.isUnbornChild,
focusNode: _firstNameFocus,
), ),
SizedBox(height: 5.0 * scaleFactor), SizedBox(height: 5.0 * scaleFactor),
_buildField( _buildField(
@ -188,6 +262,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
label: 'Nom', label: 'Nom',
controller: _lastNameController, controller: _lastNameController,
hint: 'Nom de l\'enfant', hint: 'Nom de l\'enfant',
focusNode: _lastNameFocus,
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
@ -200,27 +275,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: config.isReadonly ? null : widget.onDateSelect, onTap: config.isReadonly ? null : widget.onDateSelect,
suffixIcon: Icons.calendar_today, 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) if (widget.canBeRemoved && !config.isReadonly)
@ -230,7 +284,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: widget.onRemove, onTap: widget.onRemove,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Image.asset( child: Image.asset(
'assets/images/red_cross2.png', 'assets/images/cross.png',
width: config.isMobile ? 30 : 36, width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36,
fit: BoxFit.contain, fit: BoxFit.contain,
@ -252,6 +306,104 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
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 quavant (~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 : larrondi 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) /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
@ -310,11 +462,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// PHOTO (1/3) // PHOTO (1/3) + consentement sous la photo
Expanded( Expanded(
flex: 1, flex: 1,
child: Center( child: Center(
child: AspectRatio( child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -337,6 +492,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
), ),
), ),
const SizedBox(height: 12),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 16.0,
),
],
),
), ),
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
@ -356,35 +519,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _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<ChildCardWidget> {
), ),
), ),
), ),
const SizedBox(height: 10),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 14.0,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Champs // Champs
@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _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), const SizedBox(height: 8),
Row( _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
children: [
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
],
),
], ],
), ),
), ),
@ -525,6 +644,111 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
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" /// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) { Widget _buildReadonlyField(String label, String value) {
return Column( return Column(
@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
bool readOnly = false, bool readOnly = false,
VoidCallback? onTap, VoidCallback? onTap,
IconData? suffixIcon, IconData? suffixIcon,
FocusNode? focusNode,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
} else { } else {
return CustomAppTextField( return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
isRequired: isRequired, isRequired: isRequired,

View File

@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget {
final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol
final Color initialShadowColor; // Nouveau paramètre final Color initialShadowColor; // Nouveau paramètre
final Color hoverShadowColor; // 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({ const HoverReliefWidget({
required this.child, required this.child,
@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget {
this.enableHoverEffect = true, // Par défaut, l'effet est activé this.enableHoverEffect = true, // Par défaut, l'effet est activé
this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15) this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15)
this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3) this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3)
this.clipBehavior = Clip.antiAlias,
super.key, super.key,
}); });
@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: elevation, elevation: elevation,
shadowColor: shadowColor, shadowColor: shadowColor,
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias, clipBehavior: widget.clipBehavior,
child: widget.child, child: widget.child,
), ),
); );
@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: widget.initialElevation, // Utilise l'élévation initiale elevation: widget.initialElevation, // Utilise l'élévation initiale
shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias, clipBehavior: widget.clipBehavior,
child: widget.child, child: widget.child,
); );
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.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 'package:go_router/go_router.dart';
import 'dart:math' as math; import 'dart:math' as math;
@ -76,6 +77,19 @@ class PersonalInfoFormScreen extends StatefulWidget {
} }
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> { class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// 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<FormState>(); final _formKey = GlobalKey<FormState>();
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _firstNameController; late TextEditingController _firstNameController;
@ -89,6 +103,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
bool _sameAddress = false; bool _sameAddress = false;
bool _fieldsEnabled = true; bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -109,10 +127,57 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_sameAddress = widget.initialSameAddress ?? false; _sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields(); _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 @override
void dispose() { void dispose() {
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange);
_lastNameFocus?.dispose();
_firstNameFocus?.dispose();
_cityFocus?.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
@ -131,7 +196,35 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
} }
} }
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() { void _handleSubmit() {
if (widget.mode == DisplayMode.editable) {
_applyPersonNameFormat(_lastNameController);
_applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController);
}
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) { if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
final data = PersonalInfoData( final data = PersonalInfoData(
firstName: _firstNameController.text, firstName: _firstNameController.text,
@ -169,6 +262,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0), padding: const EdgeInsets.symmetric(vertical: 40.0),
child: _wrapScrollBodyIfEditable(
config: config,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -191,17 +286,18 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
SizedBox(height: config.isMobile ? 16 : 30), SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize), _buildCard(context, config, screenSize),
// Boutons mobile sous la carte (dans le scroll)
if (config.isMobile) ...[ if (config.isMobile) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Padding( Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté) horizontal: screenSize.width * 0.05,
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavPrevious,
child: HoverReliefWidget( child: HoverReliefWidget(
child: CustomNavigationButton( child: CustomNavigationButton(
text: 'Précédent', text: 'Précédent',
@ -219,8 +315,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
const SizedBox(width: 16), // Écart entre les boutons ),
const SizedBox(width: 16),
Expanded( Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavNext,
child: HoverReliefWidget( child: HoverReliefWidget(
child: CustomNavigationButton( child: CustomNavigationButton(
text: 'Suivant', text: 'Suivant',
@ -232,6 +332,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -241,6 +342,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
),
// Chevrons de navigation (desktop uniquement) // Chevrons de navigation (desktop uniquement)
if (!config.isMobile) ...[ if (!config.isMobile) ...[
Positioned( Positioned(
@ -522,7 +624,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
Transform.scale( _orderedFocus(
enabled: config.isEditable,
order: _focusSecondPersonToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0, scale: config.isMobile ? 0.85 : 1.0,
child: Switch( child: Switch(
value: _hasSecondPerson, value: _hasSecondPerson,
@ -535,6 +640,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
), ),
), ),
),
], ],
); );
} }
@ -558,7 +664,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
Transform.scale( _orderedFocus(
enabled: config.isEditable,
order: _focusSameAddressToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0, scale: config.isMobile ? 0.85 : 1.0,
child: Switch( child: Switch(
value: _sameAddress, value: _sameAddress,
@ -571,6 +680,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
), ),
), ),
),
], ],
); );
} }
@ -606,6 +716,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -616,6 +728,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
), ),
], ],
@ -634,6 +748,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: _phoneInputFormatters,
focusOrder: _focusPhone,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -645,6 +760,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
), ),
), ),
], ],
@ -658,6 +774,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
@ -673,6 +790,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Code postal', hint: 'Code postal',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -684,6 +802,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
), ),
), ),
], ],
@ -852,6 +972,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -862,6 +984,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -874,6 +998,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: _phoneInputFormatters,
focusOrder: _focusPhone,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -885,6 +1010,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -895,6 +1021,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -906,6 +1033,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Code postal', hint: 'Code postal',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -916,6 +1044,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
), ),
], ],
); );
@ -930,6 +1060,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
TextInputType? keyboardType, TextInputType? keyboardType,
bool enabled = true, bool enabled = true,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
double? focusOrder,
FocusNode? focusNode,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
@ -942,9 +1074,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue, value: displayValue,
); );
} else { } else {
// Mode éditable : style adapté mobile/desktop Widget field = CustomAppTextField(
return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige, style: CustomAppTextFieldStyle.beige,
@ -956,6 +1088,13 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: enabled, enabled: enabled,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
); );
if (focusOrder != null) {
field = FocusTraversalOrder(
order: NumericFocusOrder(focusOrder),
child: field,
);
}
return field;
} }
} }