From bf05b1d7d7789d15d47a9e46b19599932b2f116e Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 31 Mar 2026 00:04:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(inscription):=20email=20accus=C3=A9=20r?= =?UTF-8?q?=C3=A9ception,=20photos=20enfants,=20formulaires=20identit?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API - Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés - Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field Made-with: Cursor --- backend/.env.example | 3 + backend/src/modules/mail/mail.service.ts | 38 +++ backend/src/routes/auth/auth.module.ts | 2 + backend/src/routes/auth/auth.service.ts | 28 +- .../lib/models/user_registration_data.dart | 7 + .../creation/admin_create.dart | 47 ++-- .../creation/gestionnaires_create.dart | 47 +--- frontend/lib/screens/auth/login_screen.dart | 239 ++++++++++++------ .../auth/parent_register_step3_screen.dart | 24 +- frontend/lib/utils/email_utils.dart | 57 +++++ .../utils/parent_registration_payload.dart | 66 +++-- frontend/lib/utils/phone_utils.dart | 124 +++++++-- frontend/lib/utils/postal_utils.dart | 19 ++ .../lib/widgets/admin/parametres_panel.dart | 16 +- .../admin/relais_management_panel.dart | 9 +- frontend/lib/widgets/child_card_widget.dart | 60 ++--- .../lib/widgets/custom_app_text_field.dart | 117 +++++---- frontend/lib/widgets/email_text_field.dart | 219 ++++++++++++++++ frontend/lib/widgets/french_phone_field.dart | 127 ++++++++++ .../widgets/personal_info_form_screen.dart | 176 ++++++++++++- 20 files changed, 1150 insertions(+), 275 deletions(-) create mode 100644 frontend/lib/utils/email_utils.dart create mode 100644 frontend/lib/utils/postal_utils.dart create mode 100644 frontend/lib/widgets/email_text_field.dart create mode 100644 frontend/lib/widgets/french_phone_field.dart diff --git a/backend/.env.example b/backend/.env.example index 67081bd..94f09fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,5 +22,8 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development +# Photos (inscription). Defaut : ./uploads/photos sous le backend. Docker : UPLOAD_PHOTOS_DIR=/app/uploads/photos +# UPLOAD_PHOTOS_DIR= + # Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front # LOG_API_REQUESTS=true diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 6a1e872..df5eec9 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -98,6 +98,44 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de réception inscription parent : demande enregistrée + n° de dossier. + * L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade. + */ + async sendParentRegistrationPendingEmail( + to: string, + prenom: string, + nom: string, + numeroDossier: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`; + const html = ` +
+

Bonjour ${safe(prenom)} ${safe(nom)},

+

Nous avons bien enregistré votre demande de création de compte sur ${safe(appName)}.

+

Numéro de dossier : ${safe(numeroDossier)}

+

Votre dossier est en attente de validation par notre équipe. Vous recevrez un email lorsqu’il aura été traité.

+ +
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } + /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 2a4513a..34d4631 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, + MailModule, NumeroDossierModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 5d7d943..1d51f69 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -28,6 +28,7 @@ import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; +import { MailService } from 'src/modules/mail/mail.service'; @Injectable() export class AuthService { @@ -36,6 +37,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, + private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @@ -330,12 +332,34 @@ export class AuthService { }; }); + const numeroDossier = resultat.parent1.numero_dossier ?? ''; + + try { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', + numeroDossier, + ); + if (resultat.parent2) { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + console.error("[inscrireParentComplet] Échec envoi email de confirmation d'inscription", err); + } + return { message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', parent_id: resultat.parent1.id, co_parent_id: resultat.parent2?.id, enfants_ids: resultat.enfants.map(e => e.id), statut: StatutUtilisateurType.EN_ATTENTE, + numero_dossier: numeroDossier, }; } @@ -464,7 +488,9 @@ export class AuthService { const extension = correspondances[1]; const tamponImage = Buffer.from(correspondances[2], 'base64'); - const dossierUpload = '/app/uploads/photos'; + const dossierUpload = + (process.env.UPLOAD_PHOTOS_DIR && process.env.UPLOAD_PHOTOS_DIR.trim()) || + path.join(process.cwd(), 'uploads', 'photos'); await fs.mkdir(dossierUpload, { recursive: true }); const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index ef5a692..5e8892f 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -29,6 +29,7 @@ class ParentData { class ChildData { static const Object _unsetImage = Object(); + static const Object _unsetImageBytes = Object(); String firstName; String lastName; @@ -39,6 +40,8 @@ class ChildData { bool multipleBirth; bool isUnbornChild; File? imageFile; + /// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web). + Uint8List? imageBytes; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte ChildData({ @@ -50,6 +53,7 @@ class ChildData { this.multipleBirth = false, this.isUnbornChild = false, this.imageFile, + this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur }); @@ -62,6 +66,7 @@ class ChildData { bool? multipleBirth, bool? isUnbornChild, Object? imageFile = _unsetImage, + Object? imageBytes = _unsetImageBytes, CardColorVertical? cardColor, }) { return ChildData( @@ -73,6 +78,8 @@ class ChildData { multipleBirth: multipleBirth ?? this.multipleBirth, isUnbornChild: isUnbornChild ?? this.isUnbornChild, imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, + imageBytes: + identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, cardColor: cardColor ?? this.cardColor, ); } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index ae1477c..1db28b2 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; class AdminCreateDialog extends StatefulWidget { final AppUser? initialUser; @@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State { return null; } + String? _validateTelephone(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Téléphone'); + if (base != null) { + return base; + } + return validateFrenchNationalPhone(value, allowEmpty: false); + } + Future _submit() async { if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: _validateEmail, ); } @@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), - validator: (v) => _required(v, 'Téléphone'), + label: 'Téléphone (ex: 06 12 34 56 78)', + validator: _validateTelephone, ); } } diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 8dbed7e..a1b5f92 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State { return null; } final base = _required(value, 'Téléphone'); - if (base != null) return base; - final digits = normalizePhone(value!); - if (digits.length != 10) { - return 'Le téléphone doit contenir 10 chiffres'; + if (base != null) { + return base; } - if (!digits.startsWith('0')) { - return 'Le téléphone doit commencer par 0'; - } - return null; + return validateFrenchNationalPhone(value, allowEmpty: false); } String _toTitleCase(String raw) { @@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, readOnly: widget.readOnly, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: widget.readOnly ? null : _validateEmail, ); } @@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, readOnly: widget.readOnly, - keyboardType: TextInputType.phone, - inputFormatters: widget.readOnly - ? null - : [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), + label: 'Téléphone (ex: 06 12 34 56 78)', validator: widget.readOnly ? null : _validatePhone, ); } diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 2814b72..16ee6d9 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/bug_report_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; @@ -21,6 +22,10 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final GlobalKey> _emailFormKey = + GlobalKey>(); + late final FocusNode _emailFocus; + late final FocusNode _passwordFocus; bool _isLoading = false; String? _errorMessage; @@ -36,22 +41,49 @@ class _LoginPageState extends State { void initState() { super.initState(); _desktopRiverLogoDimensionsFuture = _getImageDimensions(); + _emailFocus = FocusNode(); + _passwordFocus = FocusNode(); + _emailFocus.addListener(_onEmailFocusChange); + } + + void _onEmailFocusChange() { + if (_emailFocus.hasFocus) { + return; + } + // Reporter au frame suivant : une mise à jour synchrone du controller pendant + // un transfert de focus (Tab) peut casser la navigation au clavier. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _emailFocus.hasFocus) { + return; + } + final normalized = normalizeEmailText(_emailController.text); + if (normalized != _emailController.text) { + _emailController.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _emailFormKey.currentState?.validate(); + }); } @override void dispose() { + _emailFocus.removeListener(_onEmailFocusChange); + _emailFocus.dispose(); + _passwordFocus.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } String? _validateEmail(String? value) { - final v = value ?? ''; + final v = value?.trim() ?? ''; if (v.isEmpty) { - return 'Veuillez entrer votre email'; + return 'Veuillez entrer votre adresse e-mail.'; } - if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) { - return 'Veuillez entrer un email valide'; + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; } return null; } @@ -205,53 +237,75 @@ class _LoginPageState extends State { height: h * 0.5, // 50% de la hauteur de l'écran child: Padding( padding: EdgeInsets.all(w * 0.02), // 2% de padding - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Champs côte à côte - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: CustomAppTextField( - controller: _emailController, - labelText: 'Email', - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 53, - fieldWidth: double.infinity, + FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - const SizedBox(width: 20), - Expanded( - child: CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password, - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: - _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 53, - fieldWidth: double.infinity, + const SizedBox(width: 20), + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password, + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - ], + ], + ), ), const SizedBox(height: 20), @@ -461,46 +515,68 @@ class _LoginPageState extends State { horizontal: 24, vertical: 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - CustomAppTextField( - controller: _emailController, - labelText: 'Email', - showLabel: false, - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - const SizedBox(height: 12), - CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - showLabel: false, - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 48, - fieldWidth: double.infinity, - ), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + showLabel: false, + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), + const SizedBox(height: 12), + FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + showLabel: false, + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), if (_errorMessage != null) ...[ const SizedBox(height: 12), Container( @@ -571,6 +647,7 @@ class _LoginPageState extends State { ), ), ), + ), Padding( padding: const EdgeInsets.only(bottom: 12, top: 8), child: Wrap( diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index 4e0cb07..b829db8 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'dart:math' as math; // Pour la rotation du chevron import 'package:image_picker/image_picker.dart'; import 'dart:io' show File; +import 'package:flutter/foundation.dart' show kIsWeb; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -152,10 +153,19 @@ class _ParentRegisterStep3ScreenState extends State { if (pickedFile != null) { if (childIndex < registrationData.children.length) { final oldChild = registrationData.children[childIndex]; - final updatedChild = oldChild.copyWith(imageFile: File(pickedFile.path)); + final bytes = await pickedFile.readAsBytes(); + if (bytes.isEmpty) return; + File? file; + if (!kIsWeb) { + try { + final f = File(pickedFile.path); + if (await f.exists()) file = f; + } catch (_) {} + } + final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); registrationData.updateChild(childIndex, updatedChild); } - } + } } catch (e) { print("Erreur image: $e"); } } @@ -285,13 +295,14 @@ class _ParentRegisterStep3ScreenState extends State { // Générer les cartes enfants for (int index = 0; index < registrationData.children.length; index++) ...[ ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), onClearImage: () => setState(() { final c = registrationData.children[index]; - registrationData.updateChild(index, c.copyWith(imageFile: null)); + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { @@ -392,13 +403,14 @@ class _ParentRegisterStep3ScreenState extends State { return Padding( padding: const EdgeInsets.only(right: 20.0), child: ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), onClearImage: () => setState(() { final c = registrationData.children[index]; - registrationData.updateChild(index, c.copyWith(imageFile: null)); + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { diff --git a/frontend/lib/utils/email_utils.dart b/frontend/lib/utils/email_utils.dart new file mode 100644 index 0000000..a6ac257 --- /dev/null +++ b/frontend/lib/utils/email_utils.dart @@ -0,0 +1,57 @@ +import 'package:flutter/services.dart'; + +/// Longueur maximale d’une adresse e-mail (RFC 5321). +const int kEmailMaxLength = 254; + +/// Trim + minuscules (usage à la perte de focus et avant validation côté API). +String normalizeEmailText(String raw) { + return raw.trim().toLowerCase(); +} + +/// Motif volontairement simple pour l’UI (pas une validation RFC complète). +/// Local + @ + domaine avec au moins un point ; pas d’espaces. +final RegExp kAppEmailPattern = RegExp( + r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$', +); + +/// Indique si [trimmed] est un e-mail plausible (insensible à la casse). +bool isValidEmailFormat(String trimmed) { + final s = normalizeEmailText(trimmed); + if (s.isEmpty || s.length > kEmailMaxLength) { + return false; + } + return kAppEmailPattern.hasMatch(s); +} + +/// Validation centralisée pour les champs e-mail. +/// +/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée. +String? validateEmail(String? raw, {bool allowEmpty = false}) { + final s = normalizeEmailText(raw ?? ''); + if (s.isEmpty) { + return allowEmpty ? null : 'L’adresse e-mail est obligatoire.'; + } + if (s.length > kEmailMaxLength) { + return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).'; + } + if (!kAppEmailPattern.hasMatch(s)) { + return 'Le format de l’adresse e-mail est incorrect.'; + } + return null; +} + +/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]). +class EmailMaxLengthFormatter extends TextInputFormatter { + const EmailMaxLengthFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length <= kEmailMaxLength) { + return newValue; + } + return oldValue; + } +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 3870a2d..2c2a639 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:typed_data'; import '../models/user_registration_data.dart'; +import 'email_utils.dart'; /// Construction du body `POST /auth/register/parent` à partir du state d'inscription. /// Aligné sur [RegisterParentCompletDto] (backend). @@ -14,9 +16,13 @@ class ParentRegistrationPayload { /// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent. static String? validateForApi(UserRegistrationData d) { final p1 = d.parent1; - if (p1.email.trim().isEmpty) { + final p1Email = normalizeEmailText(p1.email); + if (p1Email.isEmpty) { return 'L’email du parent principal est requis.'; } + if (!isValidEmailFormat(p1Email)) { + return 'L’email du parent principal n’est pas au bon format.'; + } if (p1.firstName.trim().length < 2) { return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; } @@ -41,12 +47,16 @@ class ParentRegistrationPayload { p2.lastName.trim().isNotEmpty || p2.phone.trim().isNotEmpty; if (any) { - if (p2.email.trim().isEmpty || + final p2Email = normalizeEmailText(p2.email); + if (p2Email.isEmpty || p2.firstName.trim().length < 2 || p2.lastName.trim().length < 2) { return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).'; } - if (p2.email.trim().toLowerCase() == p1.email.trim().toLowerCase()) { + if (!isValidEmailFormat(p2Email)) { + return 'L’email du co-parent n’est pas au bon format.'; + } + if (p2Email == p1Email) { return 'L’email du co-parent doit être différent de celui du parent principal.'; } final tel2 = _normalizePhone(p2.phone); @@ -114,7 +124,7 @@ class ParentRegistrationPayload { p2.postalCode.trim() == p1.postalCode.trim() && p2.city.trim() == p1.city.trim(); - body['co_parent_email'] = p2.email.trim(); + body['co_parent_email'] = normalizeEmailText(p2.email); body['co_parent_prenom'] = p2.firstName.trim(); body['co_parent_nom'] = p2.lastName.trim(); body['co_parent_meme_adresse'] = sameAddr; @@ -142,7 +152,7 @@ class ParentRegistrationPayload { static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', - 'grossesse_multiple': false, + 'grossesse_multiple': c.multipleBirth, }; final prenom = c.firstName.trim(); @@ -180,20 +190,40 @@ class ParentRegistrationPayload { /// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible. static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) { - final file = c.imageFile; - if (file == null) return null; - try { - if (!file.existsSync()) return null; - final bytes = file.readAsBytesSync(); - if (bytes.isEmpty) return null; - final b64 = base64Encode(bytes); - final safeName = prenom.isNotEmpty - ? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.jpg' - : 'enfant_${index + 1}.jpg'; - return ('data:image/jpeg;base64,$b64', safeName); - } catch (_) { - return null; + Uint8List? bytes = c.imageBytes; + if (bytes == null || bytes.isEmpty) { + final file = c.imageFile; + if (file == null) return null; + try { + if (!file.existsSync()) return null; + bytes = file.readAsBytesSync(); + } catch (_) { + return null; + } } + if (bytes.isEmpty) return null; + final b64 = base64Encode(bytes); + var mime = 'jpeg'; + if (bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + mime = 'png'; + } else if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + mime = 'jpeg'; + } else if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + mime = 'webp'; + } + } + final safeName = prenom.isNotEmpty + ? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.$mime' + : 'enfant_${index + 1}.$mime'; + return ('data:image/$mime;base64,$b64', safeName); } static void _putIfNonEmpty(Map m, String key, String value) { diff --git a/frontend/lib/utils/phone_utils.dart b/frontend/lib/utils/phone_utils.dart index 2a87b8c..ba5bde7 100644 --- a/frontend/lib/utils/phone_utils.dart +++ b/frontend/lib/utils/phone_utils.dart @@ -8,46 +8,127 @@ String normalizePhone(String raw) { return digits.length > 10 ? digits.substring(0, 10) : digits; } +/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`. +/// Chaîne vide : `true` (pas encore de saisie). +bool frenchNationalPhoneStartsWithZero(String digitsOnly) { + if (digitsOnly.isEmpty) { + return true; + } + return digitsOnly.startsWith('0'); +} + +/// Validation téléphone France : 10 chiffres, commence par `0`, 2ᵉ chiffre 1–9 (format national). +/// +/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.). +/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée. +String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) { + final trimmed = raw?.trim() ?? ''; + if (trimmed.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + final digits = normalizePhone(trimmed); + if (digits.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + if (!frenchNationalPhoneStartsWithZero(digits)) { + return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).'; + } + if (digits.length < 10) { + return 'Le numéro doit contenir 10 chiffres.'; + } + if (digits.length > 10) { + return 'Le numéro ne peut pas dépasser 10 chiffres.'; + } + if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) { + return 'Numéro de téléphone invalide.'; + } + return null; +} + /// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78"). /// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.). String formatPhoneForDisplay(String raw) { - if (raw.trim().isEmpty) return raw; + if (raw.trim().isEmpty) { + return raw; + } final normalized = normalizePhone(raw); - if (normalized.isEmpty) return raw; + if (normalized.isEmpty) { + return raw; + } + return formatFrenchPhoneDigits(normalized); +} + +/// Affiche les chiffres par paires (ex. `0612345678` → `06 12 34 56 78`). +String formatFrenchPhoneDigits(String normalizedDigits) { + if (normalizedDigits.isEmpty) { + return ''; + } final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); + for (var i = 0; i < normalizedDigits.length; i++) { + if (i > 0 && i.isEven) { + buffer.write(' '); + } + buffer.write(normalizedDigits[i]); } return buffer.toString(); } -/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres. +int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) { + final k = digitCountBeforeCursor.clamp(0, normalized.length); + if (k == 0) { + return 0; + } + return formatFrenchPhoneDigits(normalized.substring(0, k)).length; +} + +/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres. +/// +/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` → `06`). +/// - Si l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`). +/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté. class FrenchPhoneNumberFormatter extends TextInputFormatter { const FrenchPhoneNumberFormatter(); + static const String _autoPrefixFirstDigits = '1234567'; + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; - final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); - } - final formatted = buffer.toString(); + var digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + var didPrependZero = false; + + if (digits.isNotEmpty) { + final first = digits[0]; + if (first == '8' || first == '9') { + return oldValue; + } + if (first != '0' && _autoPrefixFirstDigits.contains(first)) { + digits = '0$digits'; + didPrependZero = true; + if (digits.length > 10) { + digits = digits.substring(0, 10); + } + } else if (first != '0') { + return oldValue; + } + } + + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final formatted = formatFrenchPhoneDigits(normalized); - // Conserver la position du curseur : compter les chiffres avant la sélection final sel = newValue.selection; - final digitsBeforeCursor = newValue.text + var digitsBeforeCursor = newValue.text .substring(0, sel.start.clamp(0, newValue.text.length)) .replaceAll(RegExp(r'\D'), '') .length; - final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); - final clampedOffset = newOffset.clamp(0, formatted.length); + if (didPrependZero) { + digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length); + } + + final clampedOffset = + _cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length); return TextEditingValue( text: formatted, @@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter { ); } } + +/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.). +final List frenchPhoneInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + const FrenchPhoneNumberFormatter(), +]; diff --git a/frontend/lib/utils/postal_utils.dart b/frontend/lib/utils/postal_utils.dart new file mode 100644 index 0000000..d3dfb4d --- /dev/null +++ b/frontend/lib/utils/postal_utils.dart @@ -0,0 +1,19 @@ +import 'package:flutter/services.dart'; + +/// Saisie code postal français : uniquement des chiffres, au plus 5. +final List kFrenchPostalCodeInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), +]; + +/// Valide un code postal français (exactement 5 chiffres). +String? validateFrenchPostalCode(String? raw, {bool allowEmpty = false}) { + final s = raw?.trim() ?? ''; + if (s.isEmpty) { + return allowEmpty ? null : 'Ce champ est obligatoire'; + } + if (s.length != 5 || int.tryParse(s) == null) { + return 'Le code postal doit comporter 5 chiffres.'; + } + return null; +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index 6c445d8..b99fd50 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -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 { hintText: 'admin@example.com', ), keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], ), actions: [ TextButton( @@ -191,7 +195,17 @@ class _ParametresPanelState extends State { 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'), ), diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 12327b7..614ecee 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -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', diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 220e3f8..7d93ecc 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -2,7 +2,6 @@ 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'; @@ -20,6 +19,24 @@ const String _photoConsentTooltip = /// 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 class ChildCardWidget extends StatefulWidget { @@ -174,8 +191,6 @@ class _ChildCardWidgetState extends State { ); } - 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); @@ -212,7 +227,7 @@ class _ChildCardWidgetState extends State { config: config, scaleFactor: scaleFactor, photoSide: photoSide, - currentChildImage: currentChildImage, + childData: widget.childData, initialPhotoShadow: initialPhotoShadow, hoverPhotoShadow: hoverPhotoShadow, ), @@ -311,11 +326,12 @@ class _ChildCardWidgetState extends State { required DisplayConfig config, required double scaleFactor, required double photoSide, - required File? currentChildImage, + 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 qu’avant (~10×scaleFactor). final photoClipRadius = BorderRadius.circular(photoSide * 0.14); @@ -332,12 +348,11 @@ class _ChildCardWidgetState extends State { initialShadowColor: initialPhotoShadow, hoverShadowColor: hoverPhotoShadow, // Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué). - clipBehavior: - currentChildImage != null ? Clip.none : Clip.antiAlias, + clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias, child: SizedBox( width: photoSide, height: photoSide, - child: currentChildImage == null + child: !hasPhoto ? ClipRRect( borderRadius: outerRadius, child: Center( @@ -356,15 +371,7 @@ class _ChildCardWidgetState extends State { Positioned.fill( child: ClipRRect( borderRadius: photoClipRadius, - child: kIsWeb - ? Image.network( - currentChildImage.path, - fit: BoxFit.cover, - ) - : Image.file( - currentChildImage, - fit: BoxFit.cover, - ), + child: _buildChildPhotoImage(childData, fit: BoxFit.cover), ), ), Positioned.fill( @@ -379,7 +386,7 @@ class _ChildCardWidgetState extends State { ), ), ), - if (currentChildImage != null && canInteract) + if (hasPhoto && canInteract) Positioned( top: 8 * scaleFactor, right: 8 * scaleFactor, @@ -418,8 +425,7 @@ class _ChildCardWidgetState extends State { 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( @@ -484,10 +490,8 @@ class _ChildCardWidgetState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(18), - 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), ), ), @@ -538,8 +542,6 @@ class _ChildCardWidgetState extends State { /// 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 @@ -594,10 +596,8 @@ class _ChildCardWidgetState extends State { ), 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), ), ), diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 967865a..2db1ab3 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget { final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; final List? inputFormatters; + final bool autocorrect; + final bool enableSuggestions; + final GlobalKey>? 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 { @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 { 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 d’erreur du + // validateur s’affiche 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 { 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( - 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, + 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.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, ), ), ], diff --git a/frontend/lib/widgets/email_text_field.dart b/frontend/lib/widgets/email_text_field.dart new file mode 100644 index 0000000..2792702 --- /dev/null +++ b/frontend/lib/widgets/email_text_field.dart @@ -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 createState() => _EmailTextFormFieldState(); +} + +class _EmailTextFormFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + 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 [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? onFieldSubmitted; + + @override + State createState() => _EmailCustomTextFieldState(); +} + +class _EmailCustomTextFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + 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, + ); + } +} diff --git a/frontend/lib/widgets/french_phone_field.dart b/frontend/lib/widgets/french_phone_field.dart new file mode 100644 index 0000000..011150e --- /dev/null +++ b/frontend/lib/widgets/french_phone_field.dart @@ -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 1–7, 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? autofillHints; + final TextInputAction? textInputAction; + final ValueChanged? 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, + ); + } +} diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index ac79dfd..320ffd3 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -3,6 +3,8 @@ 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; @@ -106,6 +108,9 @@ class _PersonalInfoFormScreenState extends State { FocusNode? _lastNameFocus; FocusNode? _firstNameFocus; FocusNode? _cityFocus; + FocusNode? _emailFocus; + final GlobalKey> _emailFormKey = + GlobalKey>(); @override void initState() { @@ -113,7 +118,9 @@ class _PersonalInfoFormScreenState extends State { _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); @@ -132,9 +139,11 @@ class _PersonalInfoFormScreenState extends State { _lastNameFocus = FocusNode(); _firstNameFocus = FocusNode(); _cityFocus = FocusNode(); + _emailFocus = FocusNode(); _lastNameFocus!.addListener(_onLastNameFocusChange); _firstNameFocus!.addListener(_onFirstNameFocusChange); _cityFocus!.addListener(_onCityFocusChange); + _emailFocus!.addListener(_onEmailFocusChange); } } @@ -159,6 +168,92 @@ class _PersonalInfoFormScreenState extends State { _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 d’erreur « obligatoire » au blur si le champ est encore vide : + // évite un message dès l’activation 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) { @@ -175,9 +270,11 @@ class _PersonalInfoFormScreenState extends State { _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(); @@ -225,12 +322,21 @@ class _PersonalInfoFormScreenState extends State { _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, @@ -635,7 +741,20 @@ class _PersonalInfoFormScreenState extends State { setState(() { _hasSecondPerson = value; _fieldsEnabled = value; + if (value && _sameAddress) { + _updateAddressFields(); + } }); + if (!value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Si l’utilisateur 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, ), @@ -747,8 +866,9 @@ class _PersonalInfoFormScreenState extends State { 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), @@ -761,6 +881,10 @@ class _PersonalInfoFormScreenState extends State { keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), ), ], @@ -775,6 +899,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), SizedBox(height: verticalSpacing), @@ -787,10 +914,12 @@ class _PersonalInfoFormScreenState extends State { 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), @@ -804,6 +933,9 @@ class _PersonalInfoFormScreenState extends State { enabled: _fieldsEnabled && !_sameAddress, focusOrder: _focusCity, focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ), ], @@ -997,8 +1129,9 @@ class _PersonalInfoFormScreenState extends State { 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), @@ -1011,6 +1144,10 @@ class _PersonalInfoFormScreenState extends State { keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), const SizedBox(height: 12), @@ -1022,6 +1159,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), const SizedBox(height: 12), @@ -1030,10 +1170,12 @@ class _PersonalInfoFormScreenState extends State { 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), @@ -1046,6 +1188,9 @@ class _PersonalInfoFormScreenState extends State { enabled: _fieldsEnabled && !_sameAddress, focusOrder: _focusCity, focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ], ); @@ -1062,6 +1207,8 @@ class _PersonalInfoFormScreenState extends State { List? inputFormatters, double? focusOrder, FocusNode? focusNode, + GlobalKey>? formFieldKey, + String? Function(String?)? fieldValidator, }) { if (config.isReadonly) { // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) @@ -1074,7 +1221,10 @@ class _PersonalInfoFormScreenState extends State { value: displayValue, ); } else { + final effectiveKeyboardType = keyboardType ?? TextInputType.text; + final isEmail = effectiveKeyboardType == TextInputType.emailAddress; Widget field = CustomAppTextField( + formFieldKey: formFieldKey, controller: controller, focusNode: focusNode, labelText: label, @@ -1084,9 +1234,15 @@ class _PersonalInfoFormScreenState extends State { 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( @@ -1098,12 +1254,6 @@ class _PersonalInfoFormScreenState extends State { } } - static final _phoneInputFormatters = [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ]; - /// Retourne l'asset de carte vertical correspondant à la couleur String _getVerticalCardAsset() { switch (widget.cardColor) {