[#101] [Frontend] Inscription parent — API, soumission et validation

Squash merge de develop vers master.

Livrables principaux (ticket #101 et mise au point associée) :
- Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent
- Payload DTO (parents, enfants, photos base64, CGU) et services Auth
- Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées
- Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/

Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.).

Closes #101

Made-with: Cursor
This commit is contained in:
2026-04-11 18:07:24 +02:00
parent cde676c4f9
commit fdd1e06e77
49 changed files with 3179 additions and 828 deletions
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
@@ -182,6 +183,9 @@ class _ParametresPanelState extends State<ParametresPanel> {
hintText: 'admin@example.com',
),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
inputFormatters: const [EmailMaxLengthFormatter()],
),
actions: [
TextButton(
@@ -191,7 +195,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
FilledButton(
onPressed: () {
final t = c.text.trim();
if (t.isNotEmpty) Navigator.pop(ctx, t);
if (t.isEmpty) {
return;
}
final err = validateEmail(t, allowEmpty: true);
if (err != null) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(err)),
);
return;
}
Navigator.pop(ctx, t);
},
child: const Text('Envoyer'),
),
@@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) {
return false;
}
if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) {
return false;
}
for (final day in _days) {
final ferme = _closedByDay[day] ?? false;
@@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
TextField(
controller: _ligneFixeCtrl,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
inputFormatters: frenchPhoneInputFormatters,
decoration: const InputDecoration(
labelText: 'Ligne fixe',
hintText: '01 23 45 67 89',
@@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI.
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
@@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
],
),
)
: Image.network(
photoUrl,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
Widget build(BuildContext context) {
@@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl);
if (kDebugMode) {
debugPrint(
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
'photoUrl modèle=${e.photoUrl ?? ""} | url affichée=${photoUrl.isEmpty ? "" : photoUrl}',
);
}
final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
@@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
}
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
const photoRadius = 8.0;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(photoRadius),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
@@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: Image.network(
photoUrl,
fit: BoxFit.contain,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: width,
height: height,
errorBuilder: (_, __, ___) => ColoredBox(
+54 -33
View File
@@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget {
final double checkboxSize;
final double checkmarkSizeFactor;
final double fontSize;
/// Survol (desktop) ou appui long (mobile) pour afficher un texte daide.
final String? tooltip;
const AppCustomCheckbox({
super.key,
@@ -17,48 +19,67 @@ class AppCustomCheckbox extends StatelessWidget {
this.checkboxSize = 20.0,
this.checkmarkSizeFactor = 1.4,
this.fontSize = 16.0,
this.tooltip,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value), // Inverse la valeur au clic
behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: checkboxSize,
height: checkboxSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Image.asset(
'assets/images/square.png',
height: checkboxSize,
width: checkboxSize,
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => onChanged(!value),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: checkboxSize,
height: checkboxSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Image.asset(
'assets/images/square.png',
height: checkboxSize,
width: checkboxSize,
),
if (value)
Image.asset(
'assets/images/coche.png',
height: checkboxSize * checkmarkSizeFactor,
width: checkboxSize * checkmarkSizeFactor,
),
],
),
if (value)
Image.asset(
'assets/images/coche.png',
height: checkboxSize * checkmarkSizeFactor,
width: checkboxSize * checkmarkSizeFactor,
),
],
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
label,
style: GoogleFonts.merienda(fontSize: fontSize),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(width: 10),
// Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long
Flexible(
child: Text(
label,
style: GoogleFonts.merienda(fontSize: fontSize),
overflow: TextOverflow.ellipsis, // Gérer le texte long
),
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,
),
),
],
),
],
);
}
}
+358 -132
View File
@@ -1,14 +1,41 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import '../models/user_registration_data.dart';
import '../models/card_assets.dart';
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.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';
bool _hasChildPhoto(ChildData c) {
final b = c.imageBytes;
if (b != null && b.isNotEmpty) return true;
return c.imageFile != null;
}
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
final bytes = c.imageBytes;
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(bytes, fit: fit);
}
final f = c.imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
}
/// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents
@@ -16,11 +43,14 @@ class ChildCardWidget extends StatefulWidget {
final ChildData childData;
final int childIndex;
final VoidCallback onPickImage;
/// Retire la photo sélectionnée (placeholder à la place).
final VoidCallback onClearImage;
final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged;
/// `H`, `F` ou `Autre` (API). « Inconnu » à lUI = `Autre`.
final ValueChanged<String> onGenreChanged;
final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove;
final bool canBeRemoved;
@@ -32,11 +62,12 @@ class ChildCardWidget extends StatefulWidget {
required this.childData,
required this.childIndex,
required this.onPickImage,
required this.onClearImage,
required this.onDateSelect,
required this.onFirstNameChanged,
required this.onLastNameChanged,
required this.onGenreChanged,
required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn,
required this.onRemove,
required this.canBeRemoved,
@@ -49,10 +80,24 @@ class ChildCardWidget extends StatefulWidget {
}
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 _lastNameController;
late TextEditingController _dobController;
FocusNode? _firstNameFocus;
FocusNode? _lastNameFocus;
@override
void initState() {
super.initState();
@@ -65,6 +110,38 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// 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
@@ -85,6 +162,10 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
@override
void dispose() {
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.dispose();
_lastNameFocus?.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_dobController.dispose();
@@ -110,16 +191,25 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
);
}
final File? currentChildImage = widget.childData.imageFile;
// ... (reste du code existant pour mobile/editable)
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
final Color initialPhotoShadow =
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(
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
// height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor),
@@ -132,24 +222,20 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column(
mainAxisSize: MainAxisSize.min,
children: [
// ... (contenu existant)
HoverReliefWidget(
onPressed: config.isReadonly ? null : widget.onPickImage,
borderRadius: BorderRadius.circular(10),
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
child: SizedBox(
height: 200.0 * (config.isMobile ? 0.8 : 1.0),
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),
),
),
),
_buildEditablePhotoArea(
context: context,
config: config,
scaleFactor: scaleFactor,
photoSide: photoSide,
childData: widget.childData,
initialPhotoShadow: initialPhotoShadow,
hoverPhotoShadow: hoverPhotoShadow,
),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: config.isMobile ? 13.0 : 16.0,
),
SizedBox(height: 10.0 * scaleFactor),
Row(
@@ -173,13 +259,16 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
],
),
SizedBox(height: 8.0 * scaleFactor),
_buildGenreSegment(context, config, scaleFactor),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
config: config,
scaleFactor: scaleFactor,
label: 'Prénom',
controller: _firstNameController,
hint: 'Facultatif si à naître',
hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom',
isRequired: !widget.childData.isUnbornChild,
focusNode: _firstNameFocus,
),
SizedBox(height: 5.0 * scaleFactor),
_buildField(
@@ -188,6 +277,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
label: 'Nom',
controller: _lastNameController,
hint: 'Nom de l\'enfant',
focusNode: _lastNameFocus,
),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
@@ -200,27 +290,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: config.isReadonly ? null : widget.onDateSelect,
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)
@@ -230,7 +299,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: widget.onRemove,
customBorder: const CircleBorder(),
child: Image.asset(
'assets/images/red_cross2.png',
'assets/images/cross.png',
width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36,
fit: BoxFit.contain,
@@ -252,6 +321,96 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
);
}
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
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: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? 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: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && 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)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
@@ -266,8 +425,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path;
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path;
else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
final File? currentChildImage = widget.childData.imageFile;
final cardWidth = screenSize.width / 2.0;
return SizedBox(
@@ -310,32 +468,41 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PHOTO (1/3)
// PHOTO (1/3) + consentement sous la photo
Expanded(
flex: 1,
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
],
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _hasChildPhoto(widget.childData)
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: currentChildImage != null
? (kIsWeb
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
const SizedBox(height: 12),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 16.0,
),
),
],
),
),
),
@@ -356,35 +523,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_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,
),
],
),
],
),
),
@@ -396,8 +542,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
final File? currentChildImage = widget.childData.imageFile;
return Container(
width: double.infinity,
// Pas de height fixe
@@ -452,14 +596,18 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: currentChildImage != null
? (kIsWeb
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
child: _hasChildPhoto(widget.childData)
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
const SizedBox(height: 10),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 14.0,
),
const SizedBox(height: 16),
// Champs
@@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_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),
Row(
children: [
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
],
),
const SizedBox(height: 8),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
],
),
),
@@ -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"
Widget _buildReadonlyField(String label, String value) {
return Column(
@@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
bool readOnly = false,
VoidCallback? onTap,
IconData? suffixIcon,
FocusNode? focusNode,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
} else {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
isRequired: isRequired,
@@ -0,0 +1,130 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
/// [Image.network] avec en-tête `Authorization` uniquement si lURL nest pas un fichier statique public.
///
/// Les chemins `/uploads/...` sont servis sans auth (voir back + Traefik) : ne pas envoyer de Bearer,
/// sinon en **cross-origin** (ex. admin Flutter web en local, API en prod) le navigateur peut bloquer
/// la requête (CORS) et afficher licône « image cassée ».
class AuthNetworkImage extends StatefulWidget {
const AuthNetworkImage({
super.key,
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.loadingBuilder,
this.errorBuilder,
});
final String url;
final double? width;
final double? height;
final BoxFit fit;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
static bool isPublicUploadUrl(String url) {
final u = url.toLowerCase();
return u.contains('/uploads/');
}
@override
State<AuthNetworkImage> createState() => _AuthNetworkImageState();
}
class _AuthNetworkImageState extends State<AuthNetworkImage> {
late final Future<Map<String, String>?> _headersFuture;
@override
void initState() {
super.initState();
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
_headersFuture =
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] préparation chargement url=${widget.url} | '
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
);
}
}
static Future<Map<String, String>?> _loadHeaders() async {
final t = await TokenService.getToken();
if (t == null || t.isEmpty) return null;
return {'Authorization': 'Bearer $t'};
}
ImageErrorWidgetBuilder _wrapErrorBuilder() {
return (BuildContext context, Object error, StackTrace? stackTrace) {
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error',
);
}
final inner = widget.errorBuilder;
if (inner != null) {
return inner(context, error, stackTrace);
}
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: Icon(Icons.broken_image_outlined, color: Colors.grey.shade500),
),
);
};
}
@override
Widget build(BuildContext context) {
final err = _wrapErrorBuilder();
if (AuthNetworkImage.isPublicUploadUrl(widget.url)) {
return Image.network(
widget.url,
width: widget.width,
height: widget.height,
fit: widget.fit,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
}
return FutureBuilder<Map<String, String>?>(
future: _headersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return SizedBox(
width: widget.width,
height: widget.height,
child: ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
);
}
final headers = snapshot.data;
if (kDebugMode && headers != null) {
debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)');
}
return Image.network(
widget.url,
width: widget.width,
height: widget.height,
fit: widget.fit,
headers: headers,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
},
);
}
}
+71 -44
View File
@@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget {
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters;
final bool autocorrect;
final bool enableSuggestions;
final GlobalKey<FormFieldState<String>>? formFieldKey;
const CustomAppTextField({
super.key,
@@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget {
this.textInputAction,
this.onFieldSubmitted,
this.inputFormatters,
this.autocorrect = true,
this.enableSuggestions = true,
this.formFieldKey,
});
@override
@@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
@override
Widget build(BuildContext context) {
const double fontHeightMultiplier = 1.2;
const double internalVerticalPadding = 16.0;
final double dynamicFieldHeight = widget.fieldHeight;
// Indication « non éditable » : libellé + hint en gris.
final Color labelColor =
widget.enabled ? Colors.black87 : Colors.grey;
final Color hintColor = widget.enabled
? Colors.black54.withValues(alpha: 0.7)
: Colors.grey;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
widget.labelText,
style: GoogleFonts.merienda(
fontSize: widget.labelFontSize,
color: Colors.black87,
color: labelColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
],
// Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait.
SizedBox(
width: widget.fieldWidth,
height: dynamicFieldHeight,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.centerLeft,
children: [
Positioned.fill(
@@ -112,48 +124,63 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: TextFormField(
controller: widget.controller,
focusNode: widget.focusNode,
obscureText: widget.obscureText,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.enabled,
readOnly: widget.readOnly,
onTap: widget.onTap,
style: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey),
validator: widget.validator ??
(value) {
if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired &&
(value == null || value.isEmpty)) {
return 'Ce champ est obligatoire';
}
return null;
},
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: GoogleFonts.merienda(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight:
(dynamicFieldHeight - 16.0).clamp(24.0, double.infinity),
),
child: TextFormField(
key: widget.formFieldKey,
controller: widget.controller,
focusNode: widget.focusNode,
obscureText: widget.obscureText,
keyboardType: widget.keyboardType,
autocorrect: widget.autocorrect,
enableSuggestions: widget.enableSuggestions,
inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.enabled,
readOnly: widget.readOnly,
onTap: widget.onTap,
style: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: Colors.black54.withOpacity(0.7)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
suffixIcon: widget.suffixIcon != null
? Padding(
padding: const EdgeInsets.only(right: 0.0),
child: Icon(widget.suffixIcon,
color: Colors.black54,
size: widget.inputFontSize * 1.1),
)
: null,
isDense: true,
color: Colors.black87),
validator: widget.validator ??
(value) {
if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired &&
(value == null || value.isEmpty)) {
return 'Ce champ est obligatoire';
}
return null;
},
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: hintColor),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
errorStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize * 0.75,
color: Colors.red.shade800,
height: 1.2,
),
errorMaxLines: 3,
suffixIcon: widget.suffixIcon != null
? Padding(
padding: const EdgeInsets.only(right: 0.0),
child: Icon(widget.suffixIcon,
color: Colors.black54,
size: widget.inputFontSize * 1.1),
)
: null,
),
textAlignVertical: TextAlignVertical.center,
),
textAlignVertical: TextAlignVertical.center,
),
),
],
+219
View File
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/email_utils.dart';
import 'custom_app_text_field.dart';
/// [TextFormField] e-mail : à la perte de focus → minuscules + validation immédiate.
class EmailTextFormField extends StatefulWidget {
const EmailTextFormField({
super.key,
required this.controller,
this.decoration,
this.label,
this.hint,
this.allowEmpty = false,
this.readOnly = false,
this.focusNode,
this.autovalidateMode,
this.validator,
});
final TextEditingController controller;
final InputDecoration? decoration;
final String? label;
final String? hint;
final bool allowEmpty;
final bool readOnly;
final FocusNode? focusNode;
final AutovalidateMode? autovalidateMode;
final String? Function(String?)? validator;
@override
State<EmailTextFormField> createState() => _EmailTextFormFieldState();
}
class _EmailTextFormFieldState extends State<EmailTextFormField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
late final bool _ownsFocusNode;
@override
void initState() {
super.initState();
_ownsFocusNode = widget.focusNode == null;
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus || widget.readOnly) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) {
return;
}
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
if (_ownsFocusNode) {
_focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final deco = widget.decoration ??
InputDecoration(
labelText: widget.label,
hintText: widget.hint,
border: const OutlineInputBorder(),
);
return TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
inputFormatters: const <TextInputFormatter>[EmailMaxLengthFormatter()],
autovalidateMode: widget.autovalidateMode,
decoration: deco,
validator: widget.readOnly
? null
: (widget.validator ??
(value) => validateEmail(value, allowEmpty: widget.allowEmpty)),
);
}
}
/// Même logique que [EmailTextFormField] avec le style [CustomAppTextField].
class EmailCustomTextField extends StatefulWidget {
const EmailCustomTextField({
super.key,
required this.controller,
required this.labelText,
this.hintText = '',
this.focusNode,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 22.0,
this.inputFontSize = 20.0,
this.enabled = true,
this.readOnly = false,
this.allowEmpty = false,
this.style = CustomAppTextFieldStyle.beige,
this.validator,
this.textInputAction,
this.onFieldSubmitted,
});
final TextEditingController controller;
final String labelText;
final String hintText;
final FocusNode? focusNode;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final bool allowEmpty;
final CustomAppTextFieldStyle style;
final String? Function(String?)? validator;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
@override
State<EmailCustomTextField> createState() => _EmailCustomTextFieldState();
}
class _EmailCustomTextFieldState extends State<EmailCustomTextField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
late final bool _ownsFocusNode;
@override
void initState() {
super.initState();
_ownsFocusNode = widget.focusNode == null;
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus || widget.readOnly || !widget.enabled) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) {
return;
}
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
if (_ownsFocusNode) {
_focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomAppTextField(
formFieldKey: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
labelText: widget.labelText,
hintText: widget.hintText,
style: widget.style,
fieldWidth: widget.fieldWidth,
fieldHeight: widget.fieldHeight,
labelFontSize: widget.labelFontSize,
inputFontSize: widget.inputFontSize,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
enabled: widget.enabled,
readOnly: widget.readOnly,
autofillHints: const [AutofillHints.email],
textInputAction: widget.textInputAction ?? TextInputAction.next,
onFieldSubmitted: widget.onFieldSubmitted,
inputFormatters: const [EmailMaxLengthFormatter()],
validator: widget.validator ??
((v) => validateEmail(v, allowEmpty: widget.allowEmpty)),
isRequired: false,
);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../utils/phone_utils.dart';
import 'custom_app_text_field.dart';
/// [TextFormField] téléphone France : formatage (0 automatique si 17, etc.) + validation optionnelle.
///
/// Préférer ce widget ou [frenchPhoneInputFormatters] + [validateFrenchNationalPhone] pour tout nouveau formulaire.
class FrenchPhoneTextFormField extends StatelessWidget {
const FrenchPhoneTextFormField({
super.key,
required this.controller,
this.decoration,
this.label,
this.hint,
this.allowEmpty = false,
this.readOnly = false,
this.focusNode,
this.autovalidateMode,
this.validator,
});
final TextEditingController controller;
final InputDecoration? decoration;
final String? label;
final String? hint;
final bool allowEmpty;
final bool readOnly;
final FocusNode? focusNode;
final AutovalidateMode? autovalidateMode;
final String? Function(String?)? validator;
@override
Widget build(BuildContext context) {
final deco = decoration ??
InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
);
return TextFormField(
controller: controller,
focusNode: focusNode,
readOnly: readOnly,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
autovalidateMode: autovalidateMode,
decoration: deco,
validator: readOnly
? null
: (validator ??
(value) => validateFrenchNationalPhone(value, allowEmpty: allowEmpty)),
);
}
}
/// Même logique que [FrenchPhoneTextFormField], avec le rendu [CustomAppTextField] (inscription, cartes, etc.).
class FrenchPhoneCustomTextField extends StatelessWidget {
const FrenchPhoneCustomTextField({
super.key,
required this.controller,
required this.labelText,
this.hintText = '',
this.focusNode,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 22.0,
this.inputFontSize = 20.0,
this.enabled = true,
this.readOnly = false,
this.allowEmpty = false,
this.style = CustomAppTextFieldStyle.beige,
this.validator,
this.suffixIcon,
this.onTap,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
});
final TextEditingController controller;
final String labelText;
final String hintText;
final FocusNode? focusNode;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final bool allowEmpty;
final CustomAppTextFieldStyle style;
final String? Function(String?)? validator;
final IconData? suffixIcon;
final VoidCallback? onTap;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
@override
Widget build(BuildContext context) {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: labelText,
hintText: hintText,
style: style,
fieldWidth: fieldWidth,
fieldHeight: fieldHeight,
labelFontSize: labelFontSize,
inputFontSize: inputFontSize,
keyboardType: TextInputType.phone,
enabled: enabled,
readOnly: readOnly,
onTap: onTap,
suffixIcon: suffixIcon,
inputFormatters: frenchPhoneInputFormatters,
autofillHints: autofillHints,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
validator: validator ??
((v) => validateFrenchNationalPhone(v, allowEmpty: allowEmpty)),
isRequired: false,
);
}
}
@@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget {
final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol
final Color initialShadowColor; // 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({
required this.child,
@@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget {
this.enableHoverEffect = true, // Par défaut, l'effet est activé
this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15)
this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3)
this.clipBehavior = Clip.antiAlias,
super.key,
});
@@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: elevation,
shadowColor: shadowColor,
borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias,
clipBehavior: widget.clipBehavior,
child: widget.child,
),
);
@@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: widget.initialElevation, // Utilise l'élévation initiale
shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable
borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias,
clipBehavior: widget.clipBehavior,
child: widget.child,
);
}
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:go_router/go_router.dart';
import 'dart:math' as math;
@@ -76,6 +79,19 @@ class PersonalInfoFormScreen extends StatefulWidget {
}
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>();
late TextEditingController _lastNameController;
late TextEditingController _firstNameController;
@@ -89,13 +105,22 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
bool _sameAddress = false;
bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override
void initState() {
super.initState();
_lastNameController = TextEditingController(text: widget.initialData.lastName);
_firstNameController = TextEditingController(text: widget.initialData.firstName);
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
_emailController = TextEditingController(text: widget.initialData.email);
_emailController = TextEditingController(
text: normalizeEmailText(widget.initialData.email),
);
_addressController = TextEditingController(text: widget.initialData.address);
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
_cityController = TextEditingController(text: widget.initialData.city);
@@ -109,10 +134,147 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields();
}
if (widget.mode == DisplayMode.editable) {
_lastNameFocus = FocusNode();
_firstNameFocus = FocusNode();
_cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
}
}
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 _onEmailFocusChange() {
if (_emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
// Pas derreur « obligatoire » au blur si le champ est encore vide :
// évite un message dès lactivation du parent 2 (focus / rebuild) et
// la soumission du formulaire valide toujours.
if (normalizeEmailText(_emailController.text).isEmpty) {
return;
}
_emailFormKey.currentState?.validate();
});
}
String? _validateRequiredFrenchPhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String? _validateRequiredEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire.';
}
return validateEmail(value, allowEmpty: true);
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredFrenchPhone(value);
}
String? _validateEmailIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredEmail(value);
}
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
String? _validateAddressLineIfManualEntry(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return null;
}
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
String? _validatePostalCodeField(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
return validateFrenchPostalCode(value, allowEmpty: false);
}
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
void dispose() {
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange);
_emailFocus?.removeListener(_onEmailFocusChange);
_lastNameFocus?.dispose();
_firstNameFocus?.dispose();
_cityFocus?.dispose();
_emailFocus?.dispose();
_lastNameController.dispose();
_firstNameController.dispose();
_phoneController.dispose();
@@ -131,13 +293,50 @@ 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() {
if (widget.mode == DisplayMode.editable) {
_applyPersonNameFormat(_lastNameController);
_applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController);
}
// Parent 2 désactivé : pas de contrôle sur les champs (désactivés à l’écran).
if (widget.showSecondPersonToggle && !_hasSecondPerson) {
widget.onSubmit(
PersonalInfoData(),
hasSecondPerson: false,
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
);
return;
}
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
final data = PersonalInfoData(
firstName: _firstNameController.text,
lastName: _lastNameController.text,
phone: normalizePhone(_phoneController.text),
email: _emailController.text,
email: normalizeEmailText(_emailController.text),
address: _addressController.text,
postalCode: _postalCodeController.text,
city: _cityController.text,
@@ -169,75 +368,84 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.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,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize),
// Boutons mobile sous la carte (dans le scroll)
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté)
),
child: Row(
children: [
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
const SizedBox(width: 16), // Écart entre les boutons
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
],
child: _wrapScrollBodyIfEditable(
config: config,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.stepText,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
),
const SizedBox(height: 10),
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,
),
SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize),
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05,
),
child: Row(
children: [
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavPrevious,
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavNext,
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
),
],
),
),
const SizedBox(height: 10),
],
],
],
),
),
),
),
@@ -522,17 +730,34 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis,
),
),
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
});
},
activeColor: Theme.of(context).primaryColor,
_orderedFocus(
enabled: config.isEditable,
order: _focusSecondPersonToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
if (value && _sameAddress) {
_updateAddressFields();
}
});
if (!value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Si lutilisateur a déjà recoché Parent 2, ne pas valider :
// sinon des champs vides affichent une erreur tout de suite.
if (!mounted || _hasSecondPerson) {
return;
}
_formKey.currentState?.validate();
});
}
},
activeColor: Theme.of(context).primaryColor,
),
),
),
],
@@ -558,17 +783,21 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis,
),
),
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
_orderedFocus(
enabled: config.isEditable,
order: _focusSameAddressToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
),
),
),
],
@@ -606,6 +835,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
),
),
const SizedBox(width: 20),
@@ -616,6 +847,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
),
),
],
@@ -633,7 +866,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone,
enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters,
inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
),
),
const SizedBox(width: 20),
@@ -645,6 +880,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
),
],
@@ -658,6 +898,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
SizedBox(height: verticalSpacing),
@@ -670,9 +914,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config,
label: 'Code Postal',
controller: _postalCodeController,
hint: 'Code postal',
hint: '5 chiffres',
keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
),
),
const SizedBox(width: 20),
@@ -684,6 +931,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
),
],
@@ -852,6 +1104,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
),
const SizedBox(height: 12),
@@ -862,6 +1116,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
),
const SizedBox(height: 12),
@@ -873,7 +1129,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone,
enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters,
inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
),
const SizedBox(height: 12),
@@ -885,6 +1143,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
const SizedBox(height: 12),
@@ -895,6 +1158,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
const SizedBox(height: 12),
@@ -903,9 +1170,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config,
label: 'Code Postal',
controller: _postalCodeController,
hint: 'Code postal',
hint: '5 chiffres',
keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
),
const SizedBox(height: 12),
@@ -916,6 +1186,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
],
);
@@ -930,6 +1205,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
TextInputType? keyboardType,
bool enabled = true,
List<TextInputFormatter>? inputFormatters,
double? focusOrder,
FocusNode? focusNode,
GlobalKey<FormFieldState<String>>? formFieldKey,
String? Function(String?)? fieldValidator,
}) {
if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
@@ -942,9 +1221,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue,
);
} else {
// Mode éditable : style adapté mobile/desktop
return CustomAppTextField(
final effectiveKeyboardType = keyboardType ?? TextInputType.text;
final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige,
@@ -952,10 +1234,23 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
keyboardType: keyboardType ?? TextInputType.text,
keyboardType: effectiveKeyboardType,
autocorrect: !isEmail,
enableSuggestions: !isEmail,
autofillHints: isEmail ? const [AutofillHints.email] : null,
textInputAction: isEmail ? TextInputAction.next : null,
enabled: enabled,
inputFormatters: inputFormatters,
validator: fieldValidator,
isRequired: fieldValidator == null,
);
if (focusOrder != null) {
field = FocusTraversalOrder(
order: NumericFocusOrder(focusOrder),
child: field,
);
}
return field;
}
}