feat(inscription): email accusé réception, photos enfants, formulaires identité

- 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
This commit is contained in:
MARTIN Julien 2026-03-31 00:04:38 +02:00
parent 110240b682
commit bf05b1d7d7
20 changed files with 1150 additions and 275 deletions

View File

@ -22,5 +22,8 @@ JWT_EXPIRATION_TIME=7d
# Environnement # Environnement
NODE_ENV=development 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 de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true # LOG_API_REQUESTS=true

View File

@ -98,6 +98,44 @@ export class MailService {
await this.sendEmail(to, subject, html); await this.sendEmail(to, subject, html);
} }
/**
* Accusé de réception inscription parent : demande enregistrée + n° de dossier.
* Lutilisateur est en attente de validation ; pas de lien création MDP à ce stade.
*/
async sendParentRegistrationPendingEmail(
to: string,
prenom: string,
nom: string,
numeroDossier: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsquil aura é traité.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
</div>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/** /**
* Email de refus de dossier avec lien reprise (token). * Email de refus de dossier avec lien reprise (token).
* Ticket #110 Refus sans suppression * Ticket #110 Refus sans suppression

View File

@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config'; import { AppConfigModule } from 'src/modules/config';
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
AppConfigModule, AppConfigModule,
MailModule,
NumeroDossierModule, NumeroDossierModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],

View File

@ -28,6 +28,7 @@ import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
import { AppConfigService } from 'src/modules/config/config.service'; import { AppConfigService } from 'src/modules/config/config.service';
import { validateNir } from 'src/common/utils/nir.util'; import { validateNir } from 'src/common/utils/nir.util';
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -36,6 +37,7 @@ export class AuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
private readonly mailService: MailService,
private readonly numeroDossierService: NumeroDossierService, private readonly numeroDossierService: NumeroDossierService,
@InjectRepository(Parents) @InjectRepository(Parents)
private readonly parentsRepo: Repository<Parents>, private readonly parentsRepo: Repository<Parents>,
@ -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 { return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
parent_id: resultat.parent1.id, parent_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id, co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id), enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }
@ -464,7 +488,9 @@ export class AuthService {
const extension = correspondances[1]; const extension = correspondances[1];
const tamponImage = Buffer.from(correspondances[2], 'base64'); 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 }); await fs.mkdir(dossierUpload, { recursive: true });
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;

View File

@ -29,6 +29,7 @@ class ParentData {
class ChildData { class ChildData {
static const Object _unsetImage = Object(); static const Object _unsetImage = Object();
static const Object _unsetImageBytes = Object();
String firstName; String firstName;
String lastName; String lastName;
@ -39,6 +40,8 @@ class ChildData {
bool multipleBirth; bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
Uint8List? imageBytes;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
ChildData({ ChildData({
@ -50,6 +53,7 @@ class ChildData {
this.multipleBirth = false, this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
this.imageBytes,
required this.cardColor, // Rendre requis dans le constructeur required this.cardColor, // Rendre requis dans le constructeur
}); });
@ -62,6 +66,7 @@ class ChildData {
bool? multipleBirth, bool? multipleBirth,
bool? isUnbornChild, bool? isUnbornChild,
Object? imageFile = _unsetImage, Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes,
CardColorVertical? cardColor, CardColorVertical? cardColor,
}) { }) {
return ChildData( return ChildData(
@ -73,6 +78,8 @@ class ChildData {
multipleBirth: multipleBirth ?? this.multipleBirth, multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild, isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes:
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
cardColor: cardColor ?? this.cardColor, cardColor: cardColor ?? this.cardColor,
); );
} }

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.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 { class AdminCreateDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
final base = _required(value, 'Email'); final base = _required(value, 'Email');
if (base != null) return base; if (base != null) {
final email = value!.trim(); return base;
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); }
if (!ok) return 'Format email invalide'; return validateEmail(value, allowEmpty: true);
return null;
} }
String? _validatePassword(String? value) { String? _validatePassword(String? value) {
@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
return null; 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<void> _submit() async { Future<void> _submit() async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
} }
Widget _buildEmailField() { Widget _buildEmailField() {
return TextFormField( return EmailTextFormField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, label: 'Email',
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail, validator: _validateEmail,
); );
} }
@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
} }
Widget _buildTelephoneField() { Widget _buildTelephoneField() {
return TextFormField( return FrenchPhoneTextFormField(
controller: _telephoneController, controller: _telephoneController,
keyboardType: TextInputType.phone, label: 'Téléphone (ex: 06 12 34 56 78)',
inputFormatters: [ validator: _validateTelephone,
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'),
); );
} }
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.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/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/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
final base = _required(value, 'Email'); final base = _required(value, 'Email');
if (base != null) return base; if (base != null) {
final email = value!.trim(); return base;
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); }
if (!ok) return 'Format email invalide'; return validateEmail(value, allowEmpty: true);
return null;
} }
String? _validatePassword(String? value) { String? _validatePassword(String? value) {
@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
return null; return null;
} }
final base = _required(value, 'Téléphone'); final base = _required(value, 'Téléphone');
if (base != null) return base; if (base != null) {
final digits = normalizePhone(value!); return base;
if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres';
} }
if (!digits.startsWith('0')) { return validateFrenchNationalPhone(value, allowEmpty: false);
return 'Le téléphone doit commencer par 0';
}
return null;
} }
String _toTitleCase(String raw) { String _toTitleCase(String raw) {
@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} }
Widget _buildEmailField() { Widget _buildEmailField() {
return TextFormField( return EmailTextFormField(
controller: _emailController, controller: _emailController,
readOnly: widget.readOnly, readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress, label: 'Email',
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validateEmail, validator: widget.readOnly ? null : _validateEmail,
); );
} }
@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} }
Widget _buildTelephoneField() { Widget _buildTelephoneField() {
return TextFormField( return FrenchPhoneTextFormField(
controller: _telephoneController, controller: _telephoneController,
readOnly: widget.readOnly, readOnly: widget.readOnly,
keyboardType: TextInputType.phone, label: 'Téléphone (ex: 06 12 34 56 78)',
inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validatePhone, validator: widget.readOnly ? null : _validatePhone,
); );
} }

View File

@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/bug_report_service.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/image_button.dart';
import '../../widgets/custom_app_text_field.dart'; import '../../widgets/custom_app_text_field.dart';
import '../../services/auth_service.dart'; import '../../services/auth_service.dart';
@ -21,6 +22,10 @@ class _LoginPageState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
@ -36,22 +41,49 @@ class _LoginPageState extends State<LoginScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_desktopRiverLogoDimensionsFuture = _getImageDimensions(); _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 @override
void dispose() { void dispose() {
_emailFocus.removeListener(_onEmailFocusChange);
_emailFocus.dispose();
_passwordFocus.dispose();
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
super.dispose(); super.dispose();
} }
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
final v = value ?? ''; final v = value?.trim() ?? '';
if (v.isEmpty) { 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)) { if (!isValidEmailFormat(v)) {
return 'Veuillez entrer un email valide'; return 'Ladresse e-mail nest pas valide.';
} }
return null; return null;
} }
@ -212,30 +244,49 @@ class _LoginPageState extends State<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Champs côte à côte // Champs côte à côte
Row( FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: CustomAppTextField( child: CustomAppTextField(
formFieldKey: _emailFormKey,
controller: _emailController, controller: _emailController,
focusNode: _emailFocus,
labelText: 'Email', labelText: 'Email',
hintText: 'Votre adresse email', hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress, keyboardType:
TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [ autofillHints: const [
AutofillHints.username, AutofillHints.username,
AutofillHints.email, AutofillHints.email,
], ],
inputFormatters: const [
EmailMaxLengthFormatter(),
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
_passwordFocus.requestFocus(),
validator: _validateEmail, validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande, style:
CustomAppTextFieldStyle.lavande,
fieldHeight: 53, fieldHeight: 53,
fieldWidth: double.infinity, fieldWidth: double.infinity,
), ),
), ),
),
const SizedBox(width: 20), const SizedBox(width: 20),
Expanded( Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: CustomAppTextField( child: CustomAppTextField(
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocus,
labelText: 'Mot de passe', labelText: 'Mot de passe',
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
obscureText: true, obscureText: true,
@ -246,13 +297,16 @@ class _LoginPageState extends State<LoginScreen> {
onFieldSubmitted: onFieldSubmitted:
_handlePasswordSubmitted, _handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune, style:
CustomAppTextFieldStyle.jaune,
fieldHeight: 53, fieldHeight: 53,
fieldWidth: double.infinity, fieldWidth: double.infinity,
), ),
), ),
),
], ],
), ),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// Message d'erreur // Message d'erreur
@ -464,29 +518,48 @@ class _LoginPageState extends State<LoginScreen> {
child: AutofillGroup( child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
CustomAppTextField( FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: CustomAppTextField(
formFieldKey: _emailFormKey,
controller: _emailController, controller: _emailController,
focusNode: _emailFocus,
labelText: 'Email', labelText: 'Email',
showLabel: false, showLabel: false,
hintText: 'Votre adresse email', hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress, keyboardType:
TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [ autofillHints: const [
AutofillHints.username, AutofillHints.username,
AutofillHints.email, AutofillHints.email,
], ],
inputFormatters: const [
EmailMaxLengthFormatter(),
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
_passwordFocus.requestFocus(),
validator: _validateEmail, validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande, style:
CustomAppTextFieldStyle.lavande,
fieldHeight: 48, fieldHeight: 48,
fieldWidth: double.infinity, fieldWidth: double.infinity,
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
CustomAppTextField( FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: CustomAppTextField(
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocus,
labelText: 'Mot de passe', labelText: 'Mot de passe',
showLabel: false, showLabel: false,
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
@ -495,12 +568,15 @@ class _LoginPageState extends State<LoginScreen> {
AutofillHints.password AutofillHints.password
], ],
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: _handlePasswordSubmitted, onFieldSubmitted:
_handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune, style:
CustomAppTextFieldStyle.jaune,
fieldHeight: 48, fieldHeight: 48,
fieldWidth: double.infinity, fieldWidth: double.infinity,
), ),
),
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@ -571,6 +647,7 @@ class _LoginPageState extends State<LoginScreen> {
), ),
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8), padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap( child: Wrap(

View File

@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'dart:math' as math; // Pour la rotation du chevron import 'dart:math' as math; // Pour la rotation du chevron
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io' show File; import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../widgets/hover_relief_widget.dart'; import '../../widgets/hover_relief_widget.dart';
import '../../widgets/child_card_widget.dart'; import '../../widgets/child_card_widget.dart';
import '../../widgets/custom_navigation_button.dart'; import '../../widgets/custom_navigation_button.dart';
@ -152,7 +153,16 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
if (pickedFile != null) { if (pickedFile != null) {
if (childIndex < registrationData.children.length) { if (childIndex < registrationData.children.length) {
final oldChild = registrationData.children[childIndex]; 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); registrationData.updateChild(childIndex, updatedChild);
} }
} }
@ -285,13 +295,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
// Générer les cartes enfants // Générer les cartes enfants
for (int index = 0; index < registrationData.children.length; index++) ...[ for (int index = 0; index < registrationData.children.length; index++) ...[
ChildCardWidget( ChildCardWidget(
key: ValueKey(registrationData.children[index].hashCode), key: ValueKey('parent_register_child_$index'),
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() { onClearImage: () => setState(() {
final c = registrationData.children[index]; 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), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() {
@ -392,13 +403,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 20.0), padding: const EdgeInsets.only(right: 20.0),
child: ChildCardWidget( 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], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() { onClearImage: () => setState(() {
final c = registrationData.children[index]; 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), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() {

View File

@ -0,0 +1,57 @@
import 'package:flutter/services.dart';
/// Longueur maximale dune 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 lUI (pas une validation RFC complète).
/// Local + @ + domaine avec au moins un point ; pas despaces.
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 : 'Ladresse e-mail est obligatoire.';
}
if (s.length > kEmailMaxLength) {
return 'Ladresse e-mail est trop longue ($kEmailMaxLength caractères maximum).';
}
if (!kAppEmailPattern.hasMatch(s)) {
return 'Le format de ladresse 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;
}
}

View File

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import '../models/user_registration_data.dart'; import '../models/user_registration_data.dart';
import 'email_utils.dart';
/// Construction du body `POST /auth/register/parent` à partir du state d'inscription. /// Construction du body `POST /auth/register/parent` à partir du state d'inscription.
/// Aligné sur [RegisterParentCompletDto] (backend). /// Aligné sur [RegisterParentCompletDto] (backend).
@ -14,9 +16,13 @@ class ParentRegistrationPayload {
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent. /// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
static String? validateForApi(UserRegistrationData d) { static String? validateForApi(UserRegistrationData d) {
final p1 = d.parent1; final p1 = d.parent1;
if (p1.email.trim().isEmpty) { final p1Email = normalizeEmailText(p1.email);
if (p1Email.isEmpty) {
return 'Lemail du parent principal est requis.'; return 'Lemail du parent principal est requis.';
} }
if (!isValidEmailFormat(p1Email)) {
return 'Lemail du parent principal nest pas au bon format.';
}
if (p1.firstName.trim().length < 2) { if (p1.firstName.trim().length < 2) {
return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; 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.lastName.trim().isNotEmpty ||
p2.phone.trim().isNotEmpty; p2.phone.trim().isNotEmpty;
if (any) { if (any) {
if (p2.email.trim().isEmpty || final p2Email = normalizeEmailText(p2.email);
if (p2Email.isEmpty ||
p2.firstName.trim().length < 2 || p2.firstName.trim().length < 2 ||
p2.lastName.trim().length < 2) { p2.lastName.trim().length < 2) {
return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).'; 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 'Lemail du co-parent nest pas au bon format.';
}
if (p2Email == p1Email) {
return 'Lemail du co-parent doit être différent de celui du parent principal.'; return 'Lemail du co-parent doit être différent de celui du parent principal.';
} }
final tel2 = _normalizePhone(p2.phone); final tel2 = _normalizePhone(p2.phone);
@ -114,7 +124,7 @@ class ParentRegistrationPayload {
p2.postalCode.trim() == p1.postalCode.trim() && p2.postalCode.trim() == p1.postalCode.trim() &&
p2.city.trim() == p1.city.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_prenom'] = p2.firstName.trim();
body['co_parent_nom'] = p2.lastName.trim(); body['co_parent_nom'] = p2.lastName.trim();
body['co_parent_meme_adresse'] = sameAddr; body['co_parent_meme_adresse'] = sameAddr;
@ -142,7 +152,7 @@ class ParentRegistrationPayload {
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) { static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': false, 'grossesse_multiple': c.multipleBirth,
}; };
final prenom = c.firstName.trim(); final prenom = c.firstName.trim();
@ -180,21 +190,41 @@ class ParentRegistrationPayload {
/// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible. /// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible.
static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) { static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) {
Uint8List? bytes = c.imageBytes;
if (bytes == null || bytes.isEmpty) {
final file = c.imageFile; final file = c.imageFile;
if (file == null) return null; if (file == null) return null;
try { try {
if (!file.existsSync()) return null; if (!file.existsSync()) return null;
final bytes = file.readAsBytesSync(); 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 (_) { } catch (_) {
return null; 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<String, dynamic> m, String key, String value) { static void _putIfNonEmpty(Map<String, dynamic> m, String key, String value) {
if (value.isNotEmpty) { if (value.isNotEmpty) {

View File

@ -8,46 +8,127 @@ String normalizePhone(String raw) {
return digits.length > 10 ? digits.substring(0, 10) : digits; 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 19 (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"). /// 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.). /// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "" etc.).
String formatPhoneForDisplay(String raw) { String formatPhoneForDisplay(String raw) {
if (raw.trim().isEmpty) return raw; if (raw.trim().isEmpty) {
return raw;
}
final normalized = normalizePhone(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(); final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) { for (var i = 0; i < normalizedDigits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' '); if (i > 0 && i.isEven) {
buffer.write(normalized[i]); buffer.write(' ');
}
buffer.write(normalizedDigits[i]);
} }
return buffer.toString(); 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 lutilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
/// - Sil commence déjà par **0**, aucun préfixe nest ajouté.
class FrenchPhoneNumberFormatter extends TextInputFormatter { class FrenchPhoneNumberFormatter extends TextInputFormatter {
const FrenchPhoneNumberFormatter(); const FrenchPhoneNumberFormatter();
static const String _autoPrefixFirstDigits = '1234567';
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; var didPrependZero = false;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) { if (digits.isNotEmpty) {
if (i > 0 && i.isEven) buffer.write(' '); final first = digits[0];
buffer.write(normalized[i]); if (first == '8' || first == '9') {
} return oldValue;
final formatted = buffer.toString(); }
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 sel = newValue.selection;
final digitsBeforeCursor = newValue.text var digitsBeforeCursor = newValue.text
.substring(0, sel.start.clamp(0, newValue.text.length)) .substring(0, sel.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '') .replaceAll(RegExp(r'\D'), '')
.length; .length;
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); if (didPrependZero) {
final clampedOffset = newOffset.clamp(0, formatted.length); digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length);
}
final clampedOffset =
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
return TextEditingValue( return TextEditingValue(
text: formatted, 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<TextInputFormatter> frenchPhoneInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
const FrenchPhoneNumberFormatter(),
];

View File

@ -0,0 +1,19 @@
import 'package:flutter/services.dart';
/// Saisie code postal français : uniquement des chiffres, au plus 5.
final List<TextInputFormatter> 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;
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.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'; import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
@ -182,6 +183,9 @@ class _ParametresPanelState extends State<ParametresPanel> {
hintText: 'admin@example.com', hintText: 'admin@example.com',
), ),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
inputFormatters: const [EmailMaxLengthFormatter()],
), ),
actions: [ actions: [
TextButton( TextButton(
@ -191,7 +195,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final t = c.text.trim(); 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'), child: const Text('Envoyer'),
), ),

View File

@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) {
return false; return false;
} }
if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) {
return false;
}
for (final day in _days) { for (final day in _days) {
final ferme = _closedByDay[day] ?? false; final ferme = _closedByDay[day] ?? false;
@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
TextField( TextField(
controller: _ligneFixeCtrl, controller: _ligneFixeCtrl,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: [ inputFormatters: frenchPhoneInputFormatters,
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Ligne fixe', labelText: 'Ligne fixe',
hintText: '01 23 45 67 89', hintText: '01 23 45 67 89',

View File

@ -2,7 +2,6 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../models/user_registration_data.dart'; import '../models/user_registration_data.dart';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
@ -20,6 +19,24 @@ const String _photoConsentTooltip =
/// Cadre photo (centre transparent), même dossier que `photo.png`. /// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.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 /// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents /// Utilisé dans le workflow d'inscription des parents
class ChildCardWidget extends StatefulWidget { class ChildCardWidget extends StatefulWidget {
@ -174,8 +191,6 @@ 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 final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200 ? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
@ -212,7 +227,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
config: config, config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
photoSide: photoSide, photoSide: photoSide,
currentChildImage: currentChildImage, childData: widget.childData,
initialPhotoShadow: initialPhotoShadow, initialPhotoShadow: initialPhotoShadow,
hoverPhotoShadow: hoverPhotoShadow, hoverPhotoShadow: hoverPhotoShadow,
), ),
@ -311,11 +326,12 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
required DisplayConfig config, required DisplayConfig config,
required double scaleFactor, required double scaleFactor,
required double photoSide, required double photoSide,
required File? currentChildImage, required ChildData childData,
required Color initialPhotoShadow, required Color initialPhotoShadow,
required Color hoverPhotoShadow, required Color hoverPhotoShadow,
}) { }) {
final canInteract = !config.isReadonly; final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor); final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor). // Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor).
final photoClipRadius = BorderRadius.circular(photoSide * 0.14); final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
@ -332,12 +348,11 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
initialShadowColor: initialPhotoShadow, initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow, hoverShadowColor: hoverPhotoShadow,
// Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué). // Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
currentChildImage != null ? Clip.none : Clip.antiAlias,
child: SizedBox( child: SizedBox(
width: photoSide, width: photoSide,
height: photoSide, height: photoSide,
child: currentChildImage == null child: !hasPhoto
? ClipRRect( ? ClipRRect(
borderRadius: outerRadius, borderRadius: outerRadius,
child: Center( child: Center(
@ -356,15 +371,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Positioned.fill( Positioned.fill(
child: ClipRRect( child: ClipRRect(
borderRadius: photoClipRadius, borderRadius: photoClipRadius,
child: kIsWeb child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
? Image.network(
currentChildImage.path,
fit: BoxFit.cover,
)
: Image.file(
currentChildImage,
fit: BoxFit.cover,
),
), ),
), ),
Positioned.fill( Positioned.fill(
@ -379,7 +386,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
), ),
), ),
if (currentChildImage != null && canInteract) if (hasPhoto && canInteract)
Positioned( Positioned(
top: 8 * scaleFactor, top: 8 * scaleFactor,
right: 8 * scaleFactor, right: 8 * scaleFactor,
@ -419,7 +426,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.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; else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
final File? currentChildImage = widget.childData.imageFile;
final cardWidth = screenSize.width / 2.0; final cardWidth = screenSize.width / 2.0;
return SizedBox( return SizedBox(
@ -484,10 +490,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: currentChildImage != null child: _hasChildPhoto(widget.childData)
? (kIsWeb ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain), : Image.asset('assets/images/photo.png', fit: BoxFit.contain),
), ),
), ),
@ -538,8 +542,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Carte en mode readonly MOBILE avec hauteur adaptative /// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) { Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
final File? currentChildImage = widget.childData.imageFile;
return Container( return Container(
width: double.infinity, width: double.infinity,
// Pas de height fixe // Pas de height fixe
@ -594,10 +596,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
child: currentChildImage != null child: _hasChildPhoto(widget.childData)
? (kIsWeb ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain), : Image.asset('assets/images/photo.png', fit: BoxFit.contain),
), ),
), ),

View File

@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget {
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool autocorrect;
final bool enableSuggestions;
final GlobalKey<FormFieldState<String>>? formFieldKey;
const CustomAppTextField({ const CustomAppTextField({
super.key, super.key,
@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget {
this.textInputAction, this.textInputAction,
this.onFieldSubmitted, this.onFieldSubmitted,
this.inputFormatters, this.inputFormatters,
this.autocorrect = true,
this.enableSuggestions = true,
this.formFieldKey,
}); });
@override @override
@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double fontHeightMultiplier = 1.2;
const double internalVerticalPadding = 16.0;
final double dynamicFieldHeight = widget.fieldHeight; 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
widget.labelText, widget.labelText,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.labelFontSize, fontSize: widget.labelFontSize,
color: Colors.black87, color: labelColor,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 6), 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( SizedBox(
width: widget.fieldWidth, width: widget.fieldWidth,
height: dynamicFieldHeight,
child: Stack( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: [ children: [
Positioned.fill( Positioned.fill(
@ -112,11 +124,19 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
Padding( Padding(
padding: padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight:
(dynamicFieldHeight - 16.0).clamp(24.0, double.infinity),
),
child: TextFormField( child: TextFormField(
key: widget.formFieldKey,
controller: widget.controller, controller: widget.controller,
focusNode: widget.focusNode, focusNode: widget.focusNode,
obscureText: widget.obscureText, obscureText: widget.obscureText,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
autocorrect: widget.autocorrect,
enableSuggestions: widget.enableSuggestions,
inputFormatters: widget.inputFormatters, inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints, autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
@ -126,7 +146,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
onTap: widget.onTap, onTap: widget.onTap,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.inputFontSize, fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey), color: Colors.black87),
validator: widget.validator ?? validator: widget.validator ??
(value) { (value) {
if (!widget.enabled || widget.readOnly) return null; if (!widget.enabled || widget.readOnly) return null;
@ -140,9 +160,16 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
hintText: widget.hintText, hintText: widget.hintText,
hintStyle: GoogleFonts.merienda( hintStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize, fontSize: widget.inputFontSize,
color: Colors.black54.withOpacity(0.7)), color: hintColor),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.zero, 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 suffixIcon: widget.suffixIcon != null
? Padding( ? Padding(
padding: const EdgeInsets.only(right: 0.0), padding: const EdgeInsets.only(right: 0.0),
@ -151,11 +178,11 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
size: widget.inputFontSize * 1.1), size: widget.inputFontSize * 1.1),
) )
: null, : null,
isDense: true,
), ),
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
), ),
), ),
),
], ],
), ),
), ),

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,
);
}
}

View File

@ -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,
);
}
}

View File

@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package: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 'package:go_router/go_router.dart';
import 'dart:math' as math; import 'dart:math' as math;
@ -106,6 +108,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
FocusNode? _lastNameFocus; FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus; FocusNode? _firstNameFocus;
FocusNode? _cityFocus; FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override @override
void initState() { void initState() {
@ -113,7 +118,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_lastNameController = TextEditingController(text: widget.initialData.lastName); _lastNameController = TextEditingController(text: widget.initialData.lastName);
_firstNameController = TextEditingController(text: widget.initialData.firstName); _firstNameController = TextEditingController(text: widget.initialData.firstName);
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone)); _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); _addressController = TextEditingController(text: widget.initialData.address);
_postalCodeController = TextEditingController(text: widget.initialData.postalCode); _postalCodeController = TextEditingController(text: widget.initialData.postalCode);
_cityController = TextEditingController(text: widget.initialData.city); _cityController = TextEditingController(text: widget.initialData.city);
@ -132,9 +139,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_lastNameFocus = FocusNode(); _lastNameFocus = FocusNode();
_firstNameFocus = FocusNode(); _firstNameFocus = FocusNode();
_cityFocus = FocusNode(); _cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange); _lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange); _firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange); _cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
} }
} }
@ -159,6 +168,92 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_applyPersonNameFormat(_cityController); _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) { void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text); final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) { if (formatted == controller.text) {
@ -175,9 +270,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_lastNameFocus?.removeListener(_onLastNameFocusChange); _lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange); _firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange); _cityFocus?.removeListener(_onCityFocusChange);
_emailFocus?.removeListener(_onEmailFocusChange);
_lastNameFocus?.dispose(); _lastNameFocus?.dispose();
_firstNameFocus?.dispose(); _firstNameFocus?.dispose();
_cityFocus?.dispose(); _cityFocus?.dispose();
_emailFocus?.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
@ -225,12 +322,21 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_applyPersonNameFormat(_firstNameController); _applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController); _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()) { if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
final data = PersonalInfoData( final data = PersonalInfoData(
firstName: _firstNameController.text, firstName: _firstNameController.text,
lastName: _lastNameController.text, lastName: _lastNameController.text,
phone: normalizePhone(_phoneController.text), phone: normalizePhone(_phoneController.text),
email: _emailController.text, email: normalizeEmailText(_emailController.text),
address: _addressController.text, address: _addressController.text,
postalCode: _postalCodeController.text, postalCode: _postalCodeController.text,
city: _cityController.text, city: _cityController.text,
@ -635,7 +741,20 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
setState(() { setState(() {
_hasSecondPerson = value; _hasSecondPerson = value;
_fieldsEnabled = 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, activeColor: Theme.of(context).primaryColor,
), ),
@ -747,8 +866,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone, focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -761,6 +881,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail, focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
), ),
], ],
@ -775,6 +899,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress, focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
@ -787,10 +914,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: 'Code postal', hint: '5 chiffres',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal, focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -804,6 +933,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity, focusOrder: _focusCity,
focusNode: _cityFocus, focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
), ),
], ],
@ -997,8 +1129,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone, focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1011,6 +1144,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail, focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1022,6 +1159,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress, focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1030,10 +1170,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: 'Code postal', hint: '5 chiffres',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal, focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1046,6 +1188,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity, focusOrder: _focusCity,
focusNode: _cityFocus, focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
], ],
); );
@ -1062,6 +1207,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
double? focusOrder, double? focusOrder,
FocusNode? focusNode, FocusNode? focusNode,
GlobalKey<FormFieldState<String>>? formFieldKey,
String? Function(String?)? fieldValidator,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
@ -1074,7 +1221,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue, value: displayValue,
); );
} else { } else {
final effectiveKeyboardType = keyboardType ?? TextInputType.text;
final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
Widget field = CustomAppTextField( Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
labelText: label, labelText: label,
@ -1084,9 +1234,15 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
fieldHeight: config.isMobile ? 45.0 : 53.0, fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0, labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.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, enabled: enabled,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
validator: fieldValidator,
isRequired: fieldValidator == null,
); );
if (focusOrder != null) { if (focusOrder != null) {
field = FocusTraversalOrder( field = FocusTraversalOrder(
@ -1098,12 +1254,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
} }
} }
static final _phoneInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
];
/// Retourne l'asset de carte vertical correspondant à la couleur /// Retourne l'asset de carte vertical correspondant à la couleur
String _getVerticalCardAsset() { String _getVerticalCardAsset() {
switch (widget.cardColor) { switch (widget.cardColor) {