Compare commits

..

No commits in common. "bf05b1d7d7789d15d47a9e46b19599932b2f116e" and "0a07fade40ff7beece240f88bcd6d21b6d6693f2" have entirely different histories.

33 changed files with 762 additions and 2220 deletions

View File

@ -22,8 +22,5 @@ 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,44 +98,6 @@ 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,14 +11,12 @@ 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,7 +28,6 @@ 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 {
@ -37,7 +36,6 @@ 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>,
@ -332,34 +330,12 @@ 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,
}; };
} }
@ -488,9 +464,7 @@ 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 = const dossierUpload = '/app/uploads/photos';
(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}`;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

View File

@ -1,6 +1,7 @@
import 'dart:io'; // Pour File import 'dart:io'; // Pour File
import '../models/card_assets.dart'; // Import de l'enum CardColorVertical import '../models/card_assets.dart'; // Import de l'enum CardColorVertical
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
// import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé // import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé
class ParentData { class ParentData {
@ -28,61 +29,25 @@ class ParentData {
} }
class ChildData { class ChildData {
static const Object _unsetImage = Object();
static const Object _unsetImageBytes = Object();
String firstName; String firstName;
String lastName; String lastName;
String dob; // Date de naissance ou prévisionnelle String dob; // Date de naissance ou prévisionnelle
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre;
bool photoConsent; bool photoConsent;
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({
this.firstName = '', this.firstName = '',
this.lastName = '', this.lastName = '',
this.dob = '', this.dob = '',
this.genre = '',
this.photoConsent = false, this.photoConsent = false,
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
}); });
ChildData copyWith({
String? firstName,
String? lastName,
String? dob,
String? genre,
bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild,
Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes,
CardColorVertical? cardColor,
}) {
return ChildData(
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
dob: dob ?? this.dob,
genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent,
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,
);
}
} }
// Nouvelle classe pour les détails bancaires // Nouvelle classe pour les détails bancaires

View File

@ -1,10 +1,8 @@
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;
@ -63,10 +61,11 @@ 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) { if (base != null) return base;
return base; final email = value!.trim();
} final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
return validateEmail(value, allowEmpty: true); if (!ok) return 'Format email invalide';
return null;
} }
String? _validatePassword(String? value) { String? _validatePassword(String? value) {
@ -79,17 +78,6 @@ 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;
@ -317,9 +305,13 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
} }
Widget _buildEmailField() { Widget _buildEmailField() {
return EmailTextFormField( return TextFormField(
controller: _emailController, controller: _emailController,
label: 'Email', keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail, validator: _validateEmail,
); );
} }
@ -354,10 +346,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
} }
Widget _buildTelephoneField() { Widget _buildTelephoneField() {
return FrenchPhoneTextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
label: 'Téléphone (ex: 06 12 34 56 78)', keyboardType: TextInputType.phone,
validator: _validateTelephone, 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'),
); );
} }
} }

View File

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

View File

@ -13,7 +13,20 @@ class AmRegisterStep1Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false); final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
final initialData = PersonalInfoData( // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
PersonalInfoData initialData;
if (registrationData.firstName.isEmpty) {
initialData = PersonalInfoData(
firstName: 'Marie',
lastName: 'DUBOIS',
phone: '0696345678',
email: 'marie.dubois@ptits-pas.fr',
address: '25 Rue de la République',
postalCode: '95870',
city: 'Bezons',
);
} else {
initialData = PersonalInfoData(
firstName: registrationData.firstName, firstName: registrationData.firstName,
lastName: registrationData.lastName, lastName: registrationData.lastName,
phone: registrationData.phone, phone: registrationData.phone,
@ -22,6 +35,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
postalCode: registrationData.postalCode, postalCode: registrationData.postalCode,
city: registrationData.city, city: registrationData.city,
); );
}
return PersonalInfoFormScreen( return PersonalInfoFormScreen(
stepText: 'Étape 1/4', stepText: 'Étape 1/4',

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import '../../models/am_registration_data.dart'; import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
@ -15,9 +17,24 @@ class AmRegisterStep2Screen extends StatefulWidget {
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> { class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
String? _photoPathFramework; String? _photoPathFramework;
File? _photoFile;
Future<void> _pickPhoto() async { Future<void> _pickPhoto() async {
// TODO: brancher ImagePicker ; ne pas préremplir de chemin factice // TODO: Remplacer par la vraie logique ImagePicker
// final imagePicker = ImagePicker();
// final pickedFile = await imagePicker.pickImage(source: ImageSource.gallery);
// if (pickedFile != null) {
// setState(() {
// _photoFile = File(pickedFile.path);
// _photoPathFramework = pickedFile.path;
// });
// } else {
setState(() {
_photoPathFramework = 'assets/images/icon_assmat.png';
_photoFile = null;
});
// }
print("Photo sélectionnée: $_photoPathFramework");
} }
@override @override
@ -36,6 +53,20 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
capacity: registrationData.capacity, capacity: registrationData.capacity,
); );
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
initialData = ProfessionalInfoData(
photoPath: 'assets/images/icon_assmat.png',
photoConsent: true,
dateOfBirth: DateTime(1980, 6, 8),
birthCity: 'Bezons',
birthCountry: 'France',
nir: '280062A00100191',
agrementNumber: 'AGR-2019-095001',
capacity: 4,
);
}
return ProfessionalInfoFormScreen( return ProfessionalInfoFormScreen(
stepText: 'Étape 2/4', stepText: 'Étape 2/4',
title: 'Vos informations professionnelles', title: 'Vos informations professionnelles',

View File

@ -13,13 +13,22 @@ class AmRegisterStep3Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = Provider.of<AmRegistrationData>(context, listen: false); final data = Provider.of<AmRegistrationData>(context, listen: false);
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
String initialText = data.presentationText;
bool initialCgu = data.cguAccepted;
if (initialText.isEmpty) {
initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.';
initialCgu = true;
}
return PresentationFormScreen( return PresentationFormScreen(
stepText: 'Étape 3/4', stepText: 'Étape 3/4',
title: 'Présentation et Conditions', title: 'Présentation et Conditions',
cardColor: CardColorHorizontal.peach, cardColor: CardColorHorizontal.peach,
textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...', textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...',
initialText: data.presentationText, initialText: initialText,
initialCguAccepted: data.cguAccepted, initialCguAccepted: initialCgu,
previousRoute: '/am-register-step2', previousRoute: '/am-register-step2',
onSubmit: (text, cguAccepted) { onSubmit: (text, cguAccepted) {
data.updatePresentationAndCgu( data.updatePresentationAndCgu(

View File

@ -5,7 +5,6 @@ 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';
@ -22,10 +21,6 @@ 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;
@ -41,49 +36,22 @@ 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?.trim() ?? ''; final v = value ?? '';
if (v.isEmpty) { if (v.isEmpty) {
return 'Veuillez entrer votre adresse e-mail.'; return 'Veuillez entrer votre email';
} }
if (!isValidEmailFormat(v)) { if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
return 'Ladresse e-mail nest pas valide.'; return 'Veuillez entrer un email valide';
} }
return null; return null;
} }
@ -244,49 +212,30 @@ class _LoginPageState extends State<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Champs côte à côte // Champs côte à côte
FocusTraversalGroup( Row(
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: keyboardType: TextInputType.emailAddress,
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: style: CustomAppTextFieldStyle.lavande,
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,
@ -297,16 +246,13 @@ class _LoginPageState extends State<LoginScreen> {
onFieldSubmitted: onFieldSubmitted:
_handlePasswordSubmitted, _handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: style: CustomAppTextFieldStyle.jaune,
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
@ -518,48 +464,29 @@ 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),
FocusTraversalOrder( CustomAppTextField(
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: keyboardType: TextInputType.emailAddress,
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: style: CustomAppTextFieldStyle.lavande,
CustomAppTextFieldStyle.lavande,
fieldHeight: 48, fieldHeight: 48,
fieldWidth: double.infinity, fieldWidth: double.infinity,
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
FocusTraversalOrder( CustomAppTextField(
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',
@ -568,15 +495,12 @@ class _LoginPageState extends State<LoginScreen> {
AutofillHints.password AutofillHints.password
], ],
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: onFieldSubmitted: _handlePasswordSubmitted,
_handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: style: CustomAppTextFieldStyle.jaune,
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(
@ -647,7 +571,6 @@ 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:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/personal_info_form_screen.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
@ -14,7 +15,22 @@ class ParentRegisterStep1Screen extends StatelessWidget {
final registrationData = Provider.of<UserRegistrationData>(context, listen: false); final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
final parent1 = registrationData.parent1; final parent1 = registrationData.parent1;
final initialData = PersonalInfoData( // Générer des données de test si vide
PersonalInfoData initialData;
if (parent1.firstName.isEmpty) {
final genFirstName = DataGenerator.firstName();
final genLastName = DataGenerator.lastName();
initialData = PersonalInfoData(
firstName: genFirstName,
lastName: genLastName,
phone: DataGenerator.phone(),
email: DataGenerator.email(genFirstName, genLastName),
address: DataGenerator.address(),
postalCode: DataGenerator.postalCode(),
city: DataGenerator.city(),
);
} else {
initialData = PersonalInfoData(
firstName: parent1.firstName, firstName: parent1.firstName,
lastName: parent1.lastName, lastName: parent1.lastName,
phone: parent1.phone, phone: parent1.phone,
@ -23,6 +39,7 @@ class ParentRegisterStep1Screen extends StatelessWidget {
postalCode: parent1.postalCode, postalCode: parent1.postalCode,
city: parent1.city, city: parent1.city,
); );
}
return PersonalInfoFormScreen( return PersonalInfoFormScreen(
stepText: 'Étape 1/5', stepText: 'Étape 1/5',

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/personal_info_form_screen.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
@ -18,17 +19,21 @@ class ParentRegisterStep2Screen extends StatelessWidget {
bool hasParent2 = parent2 != null; bool hasParent2 = parent2 != null;
bool sameAddress = false; bool sameAddress = false;
// Générer des données de test si vide
PersonalInfoData initialData; PersonalInfoData initialData;
if (parent2 == null || parent2.firstName.isEmpty) { if (parent2 == null || parent2.firstName.isEmpty) {
sameAddress = false; final genFirstName = DataGenerator.firstName();
final genLastName = DataGenerator.lastName();
sameAddress = DataGenerator.boolean();
initialData = PersonalInfoData( initialData = PersonalInfoData(
firstName: parent2?.firstName ?? '', firstName: genFirstName,
lastName: parent2?.lastName ?? '', lastName: genLastName,
phone: parent2?.phone ?? '', phone: DataGenerator.phone(),
email: parent2?.email ?? '', email: DataGenerator.email(genFirstName, genLastName),
address: parent2?.address ?? '', address: sameAddress ? parent1.address : DataGenerator.address(),
postalCode: parent2?.postalCode ?? '', postalCode: sameAddress ? parent1.postalCode : DataGenerator.postalCode(),
city: parent2?.city ?? '', city: sameAddress ? parent1.city : DataGenerator.city(),
); );
} else { } else {
sameAddress = (parent2.address == parent1.address && sameAddress = (parent2.address == parent1.address &&

View File

@ -3,16 +3,15 @@ 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';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
import '../../config/display_config.dart'; import '../../config/display_config.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
class ParentRegisterStep3Screen extends StatefulWidget { class ParentRegisterStep3Screen extends StatefulWidget {
// final UserRegistrationData registrationData; // Supprimé // final UserRegistrationData registrationData; // Supprimé
@ -76,22 +75,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
super.dispose(); super.dispose();
} }
/// Même logique que nom / prénom parent : normalisation avant passage à létape suivante.
void _normalizeChildrenNamesAndGoToStep4(
BuildContext context,
UserRegistrationData registrationData,
) {
for (var i = 0; i < registrationData.children.length; i++) {
final c = registrationData.children[i];
final fn = formatPersonNameCase(c.firstName);
final ln = formatPersonNameCase(c.lastName);
if (fn != c.firstName || ln != c.lastName) {
registrationData.updateChild(i, c.copyWith(firstName: fn, lastName: ln));
}
}
context.go('/parent-register-step4');
}
void _scrollListener() { void _scrollListener() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final position = _scrollController.position; final position = _scrollController.position;
@ -109,6 +92,8 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
void _addChild(UserRegistrationData registrationData) { // Prend registrationData void _addChild(UserRegistrationData registrationData) { // Prend registrationData
setState(() { setState(() {
bool isUnborn = DataGenerator.boolean();
// Trouver la première couleur non utilisée // Trouver la première couleur non utilisée
CardColorVertical cardColor = _childCardColors.firstWhere( CardColorVertical cardColor = _childCardColors.firstWhere(
(color) => !_usedColors.contains(color), (color) => !_usedColors.contains(color),
@ -117,11 +102,11 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final newChild = ChildData( final newChild = ChildData(
lastName: registrationData.parent1.lastName, lastName: registrationData.parent1.lastName,
firstName: '', firstName: DataGenerator.firstName(),
dob: '', dob: DataGenerator.dob(isUnborn: isUnborn),
isUnbornChild: false, isUnbornChild: isUnborn,
photoConsent: false, photoConsent: DataGenerator.boolean(),
multipleBirth: false, multipleBirth: DataGenerator.boolean(),
cardColor: cardColor, cardColor: cardColor,
); );
registrationData.addChild(newChild); registrationData.addChild(newChild);
@ -153,16 +138,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 bytes = await pickedFile.readAsBytes(); final updatedChild = ChildData(
if (bytes.isEmpty) return; firstName: oldChild.firstName,
File? file; lastName: oldChild.lastName,
if (!kIsWeb) { dob: oldChild.dob,
try { photoConsent: oldChild.photoConsent,
final f = File(pickedFile.path); multipleBirth: oldChild.multipleBirth,
if (await f.exists()) file = f; isUnbornChild: oldChild.isUnbornChild,
} catch (_) {} imageFile: File(pickedFile.path),
} cardColor: oldChild.cardColor,
final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); );
registrationData.updateChild(childIndex, updatedChild); registrationData.updateChild(childIndex, updatedChild);
} }
} }
@ -203,8 +188,15 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
); );
if (picked != null) { if (picked != null) {
final oldChild = registrationData.children[childIndex]; final oldChild = registrationData.children[childIndex];
final updatedChild = oldChild.copyWith( final updatedChild = ChildData(
firstName: oldChild.firstName,
lastName: oldChild.lastName,
dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}", dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}",
photoConsent: oldChild.photoConsent,
multipleBirth: oldChild.multipleBirth,
isUnbornChild: oldChild.isUnbornChild,
imageFile: oldChild.imageFile,
cardColor: oldChild.cardColor,
); );
registrationData.updateChild(childIndex, updatedChild); registrationData.updateChild(childIndex, updatedChild);
} }
@ -295,42 +287,40 @@ 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('parent_register_child_$index'), key: ValueKey(registrationData.children[index].hashCode),
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
final c = registrationData.children[index]; firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
registrationData.updateChild(index, c.copyWith(firstName: value)); multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
}), ))),
onLastNameChanged: (value) => setState(() { onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
final c = registrationData.children[index]; firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
registrationData.updateChild(index, c.copyWith(lastName: value)); multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
}), ))),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
},
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
}, },
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
var g = oldChild.genre; registrationData.updateChild(index, ChildData(
if (!newValue && g == 'Autre') { firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
g = ''; photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
} imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
registrationData.updateChild( ));
index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
}, },
onRemove: () => _removeChild(index, registrationData), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
@ -353,7 +343,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
const SizedBox(height: 30), const SizedBox(height: 30),
// Boutons navigation en bas du scroll // Boutons navigation en bas du scroll
_buildMobileButtons(context, config, screenSize, registrationData), _buildMobileButtons(context, config, screenSize),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@ -403,42 +393,40 @@ 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('parent_register_child_$index'), key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() { onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
final c = registrationData.children[index]; firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
registrationData.updateChild(index, c.copyWith(firstName: value)); multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
}), ))),
onLastNameChanged: (value) => setState(() { onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
final c = registrationData.children[index]; firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
registrationData.updateChild(index, c.copyWith(lastName: value)); multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
}), ))),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
},
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
}, },
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
var g = oldChild.genre; registrationData.updateChild(index, ChildData(
if (!newValue && g == 'Autre') { firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
g = ''; photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
} imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
registrationData.updateChild( ));
index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
}, },
onRemove: () => _removeChild(index, registrationData), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
@ -467,12 +455,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
} }
/// Boutons navigation mobile /// Boutons navigation mobile
Widget _buildMobileButtons( Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
BuildContext context,
DisplayConfig config,
Size screenSize,
UserRegistrationData registrationData,
) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
@ -500,7 +483,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
text: 'Suivant', text: 'Suivant',
style: NavigationButtonStyle.green, style: NavigationButtonStyle.green,
onPressed: () { onPressed: () {
_normalizeChildrenNamesAndGoToStep4(context, registrationData); context.go('/parent-register-step4');
}, },
width: double.infinity, width: double.infinity,
height: 50, height: 50,

View File

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../widgets/presentation_form_screen.dart'; import '../../widgets/presentation_form_screen.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
import '../../utils/data_generator.dart';
class ParentRegisterStep4Screen extends StatelessWidget { class ParentRegisterStep4Screen extends StatelessWidget {
const ParentRegisterStep4Screen({super.key}); const ParentRegisterStep4Screen({super.key});
@ -13,13 +14,22 @@ class ParentRegisterStep4Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<UserRegistrationData>(context, listen: false); final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
// Générer un texte de test si vide
String initialText = registrationData.motivationText;
bool initialCgu = registrationData.cguAccepted;
if (initialText.isEmpty) {
initialText = DataGenerator.motivation();
initialCgu = true;
}
return PresentationFormScreen( return PresentationFormScreen(
stepText: 'Étape 4/5', stepText: 'Étape 4/5',
title: 'Motivation de votre demande', title: 'Motivation de votre demande',
cardColor: CardColorHorizontal.green, cardColor: CardColorHorizontal.green,
textFieldHint: 'Écrivez ici pour motiver votre demande...', textFieldHint: 'Écrivez ici pour motiver votre demande...',
initialText: registrationData.motivationText, initialText: initialText,
initialCguAccepted: registrationData.cguAccepted, initialCguAccepted: initialCgu,
previousRoute: '/parent-register-step3', previousRoute: '/parent-register-step3',
onSubmit: (text, cguAccepted) { onSubmit: (text, cguAccepted) {
registrationData.updateMotivation(text); registrationData.updateMotivation(text);

View File

@ -13,7 +13,6 @@ import '../../widgets/custom_navigation_button.dart';
import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/personal_info_form_screen.dart';
import '../../widgets/child_card_widget.dart'; import '../../widgets/child_card_widget.dart';
import '../../widgets/presentation_form_screen.dart'; import '../../widgets/presentation_form_screen.dart';
import '../../services/auth_service.dart';
class ParentRegisterStep5Screen extends StatefulWidget { class ParentRegisterStep5Screen extends StatefulWidget {
const ParentRegisterStep5Screen({super.key}); const ParentRegisterStep5Screen({super.key});
@ -23,36 +22,6 @@ class ParentRegisterStep5Screen extends StatefulWidget {
} }
class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> { class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
bool _isSubmitting = false;
Future<void> _submitRegistration(BuildContext context, UserRegistrationData data) async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
await AuthService.registerParent(data);
if (!context.mounted) return;
_showSuccessModal(context);
} catch (e) {
if (!context.mounted) return;
final msg = e.toString().replaceAll('Exception: ', '');
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Envoi impossible', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
content: Text(msg, style: GoogleFonts.merienda(fontSize: 14)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
),
],
),
);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<UserRegistrationData>(context); final registrationData = Provider.of<UserRegistrationData>(context);
@ -133,11 +102,12 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
Expanded( Expanded(
child: HoverReliefWidget( child: HoverReliefWidget(
child: CustomNavigationButton( child: CustomNavigationButton(
text: _isSubmitting ? 'Envoi…' : 'Soumettre', text: 'Soumettre',
style: NavigationButtonStyle.green, style: NavigationButtonStyle.green,
onPressed: _isSubmitting onPressed: () {
? () {} print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
: () => _submitRegistration(context, registrationData), _showConfirmationModal(context);
},
width: double.infinity, width: double.infinity,
height: 50, height: 50,
fontSize: 16, fontSize: 16,
@ -148,19 +118,17 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
), ),
) )
else else
_isSubmitting ImageButton(
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
)
: ImageButton(
bg: 'assets/images/bg_green.png', bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande', text: 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F), textColor: const Color(0xFF2D6A4F),
width: 350, width: 350,
height: 50, height: 50,
fontSize: 18, fontSize: 18,
onPressed: () => _submitRegistration(context, registrationData), onPressed: () {
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
_showConfirmationModal(context);
},
), ),
], ],
), ),
@ -248,12 +216,11 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
childIndex: index, childIndex: index,
mode: DisplayMode.readonly, mode: DisplayMode.readonly,
onPickImage: () {}, onPickImage: () {},
onClearImage: () {},
onDateSelect: () {}, onDateSelect: () {},
onFirstNameChanged: (v) {}, onFirstNameChanged: (v) {},
onLastNameChanged: (v) {}, onLastNameChanged: (v) {},
onGenreChanged: (v) {},
onTogglePhotoConsent: (v) {}, onTogglePhotoConsent: (v) {},
onToggleMultipleBirth: (v) {},
onToggleIsUnborn: (v) {}, onToggleIsUnborn: (v) {},
onRemove: () {}, onRemove: () {},
canBeRemoved: false, canBeRemoved: false,
@ -272,14 +239,14 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
cardColor: CardColorHorizontal.green, // Changé de pink à green cardColor: CardColorHorizontal.green, // Changé de pink à green
textFieldHint: '', textFieldHint: '',
initialText: data.motivationText, initialText: data.motivationText,
initialCguAccepted: data.cguAccepted, initialCguAccepted: true, // Toujours true ici car déjà passé
previousRoute: '', previousRoute: '',
onSubmit: (t, c) {}, onSubmit: (t, c) {},
onEdit: () => context.go('/parent-register-step4'), onEdit: () => context.go('/parent-register-step4'),
); );
} }
void _showSuccessModal(BuildContext context) { void _showConfirmationModal(BuildContext context) {
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,

View File

@ -4,8 +4,6 @@ import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../models/am_registration_data.dart'; import '../models/am_registration_data.dart';
import '../models/user_registration_data.dart';
import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart'; import 'api/api_config.dart';
import 'api/tokenService.dart'; import 'api/tokenService.dart';
import '../utils/nir_utils.dart'; import '../utils/nir_utils.dart';
@ -190,31 +188,6 @@ class AuthService {
throw Exception(message); throw Exception(message);
} }
/// Inscription parent complète (POST /auth/register/parent).
/// Succès : 201, pas de session rediriger vers le login.
static Future<void> registerParent(UserRegistrationData data) async {
final validationError = ParentRegistrationPayload.validateForApi(data);
if (validationError != null) {
throw Exception(validationError);
}
final body = ParentRegistrationPayload.toJson(data);
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'),
headers: ApiConfig.headers,
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
final message = _extractErrorMessage(decoded, response.statusCode);
throw Exception(message);
}
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
static String _extractErrorMessage(dynamic decoded, int statusCode) { static String _extractErrorMessage(dynamic decoded, int statusCode) {
const fallback = 'Erreur lors de l\'inscription'; const fallback = 'Erreur lors de l\'inscription';

View File

@ -0,0 +1,70 @@
import 'dart:math';
class DataGenerator {
static final Random _random = Random();
// Méthodes publiques pour la génération de nombres aléatoires
static int randomInt(int max) => _random.nextInt(max);
static int randomIntInRange(int min, int max) => min + _random.nextInt(max - min);
static bool randomBool() => _random.nextBool();
static final List<String> _firstNames = [
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Félix', 'Gabrielle', 'Hugo', 'Inès', 'Jules',
'Léa', 'Manon', 'Nathan', 'Oscar', 'Pauline', 'Quentin', 'Raphaël', 'Sophie', 'Théo', 'Victoire'
];
static final List<String> _lastNames = [
'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand', 'Leroy', 'Moreau',
'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David', 'Bertrand', 'Roux', 'Vincent', 'Fournier'
];
static final List<String> _addressSuffixes = [
'Rue de la Paix', 'Boulevard des Rêves', 'Avenue du Soleil', 'Place des Étoiles', 'Chemin des Champs'
];
static final List<String> _motivationSnippets = [
'Nous cherchons une personne de confiance.',
'Nos horaires sont atypiques.',
'Notre enfant est plein de vie.',
'Nous souhaitons une garde à temps plein.',
'Une adaptation en douceur est primordiale pour nous.',
'Nous avons hâte de vous rencontrer.',
'La pédagogie Montessori nous intéresse.'
];
static String firstName() => _firstNames[_random.nextInt(_firstNames.length)];
static String lastName() => _lastNames[_random.nextInt(_lastNames.length)];
static String address() => "${_random.nextInt(100) + 1} ${_addressSuffixes[_random.nextInt(_addressSuffixes.length)]}";
static String postalCode() => "750${_random.nextInt(10)}${_random.nextInt(10)}";
static String city() => "Paris";
static String phone() => "06${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}";
static String email(String firstName, String lastName) => "${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com";
static String password() => "password123"; // Simple pour le test
static String dob({bool isUnborn = false}) {
final now = DateTime.now();
if (isUnborn) {
final provisionalDate = now.add(Duration(days: _random.nextInt(180) + 30)); // Entre 1 et 7 mois dans le futur
return "${provisionalDate.day.toString().padLeft(2, '0')}/${provisionalDate.month.toString().padLeft(2, '0')}/${provisionalDate.year}";
} else {
final birthYear = now.year - _random.nextInt(3); // Enfants de 0 à 2 ans
final birthMonth = _random.nextInt(12) + 1;
final birthDay = _random.nextInt(28) + 1; // Simple, évite les pbs de jours/mois
return "${birthDay.toString().padLeft(2, '0')}/${birthMonth.toString().padLeft(2, '0')}/${birthYear}";
}
}
static bool boolean() => _random.nextBool();
static String motivation() {
int count = _random.nextInt(3) + 2; // 2 à 4 phrases
List<String> chosenSnippets = [];
while(chosenSnippets.length < count) {
String snippet = _motivationSnippets[_random.nextInt(_motivationSnippets.length)];
if (!chosenSnippets.contains(snippet)) {
chosenSnippets.add(snippet);
}
}
return chosenSnippets.join(' ');
}
}

View File

@ -1,57 +0,0 @@
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,32 +0,0 @@
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return trimmed;
}
final words = trimmed.split(RegExp(r'\s+'));
return words.map(_capitalizeComposedWord).join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) {
return word;
}
final lower = word.toLowerCase();
const separators = <String>{'-', "'", ''};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}

View File

@ -1,255 +0,0 @@
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).
class ParentRegistrationPayload {
ParentRegistrationPayload._();
static final RegExp _phoneFr = RegExp(r'^(\+33|0)[1-9](\d{2}){4}$');
/// Aligné sur `GenreType` (backend) : JSON exact `H`, `F`, `Autre`.
static const Set<String> apiGenres = {'H', 'F', 'Autre'};
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
static String? validateForApi(UserRegistrationData d) {
final p1 = d.parent1;
final p1Email = normalizeEmailText(p1.email);
if (p1Email.isEmpty) {
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) {
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
}
if (p1.lastName.trim().length < 2) {
return 'Le nom du parent principal doit contenir au moins 2 caractères.';
}
final tel = _normalizePhone(p1.phone);
if (!_phoneFr.hasMatch(tel)) {
return 'Le numéro de téléphone du parent principal nest pas valide (ex. 0612345678).';
}
if (!d.cguAccepted) {
return 'Vous devez accepter les CGU et la politique de confidentialité.';
}
if (d.children.isEmpty) {
return 'Au moins un enfant est requis.';
}
final p2 = d.parent2;
if (p2 != null) {
final any = p2.email.trim().isNotEmpty ||
p2.firstName.trim().isNotEmpty ||
p2.lastName.trim().isNotEmpty ||
p2.phone.trim().isNotEmpty;
if (any) {
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 (!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.';
}
final tel2 = _normalizePhone(p2.phone);
if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) {
return 'Le numéro de téléphone du co-parent nest pas valide.';
}
}
}
for (var i = 0; i < d.children.length; i++) {
final c = d.children[i];
final label = 'Enfant ${i + 1}';
if (!c.photoConsent) {
return '$label : le consentement photo est obligatoire.';
}
if (c.isUnbornChild) {
final due = _ddMmYyyyToIso(c.dob);
if (due == null) {
return '$label : indiquez une date de naissance prévisionnelle.';
}
if (!apiGenres.contains(c.genre)) {
return '$label : indiquez Fille, Garçon ou Inconnu.';
}
} else {
if (c.firstName.trim().length < 2) {
return '$label : le prénom doit contenir au moins 2 caractères.';
}
final birth = _ddMmYyyyToIso(c.dob);
if (birth == null) {
return '$label : indiquez une date de naissance valide.';
}
if (c.genre != 'H' && c.genre != 'F') {
return '$label : indiquez Fille ou Garçon.';
}
}
}
return null;
}
static Map<String, dynamic> toJson(UserRegistrationData d) {
final p1 = d.parent1;
final tel = _normalizePhone(p1.phone);
final body = <String, dynamic>{
'email': p1.email.trim(),
'prenom': p1.firstName.trim(),
'nom': p1.lastName.trim(),
'telephone': tel,
'acceptation_cgu': d.cguAccepted,
'acceptation_privacy': d.cguAccepted,
};
_putIfNonEmpty(body, 'adresse', p1.address.trim());
_putIfNonEmpty(body, 'code_postal', p1.postalCode.trim());
_putIfNonEmpty(body, 'ville', p1.city.trim());
final p2 = d.parent2;
if (p2 != null &&
p2.email.trim().isNotEmpty &&
p2.firstName.trim().length >= 2 &&
p2.lastName.trim().length >= 2) {
final tel2 = _normalizePhone(p2.phone);
final sameAddr = p2.address.trim() == p1.address.trim() &&
p2.postalCode.trim() == p1.postalCode.trim() &&
p2.city.trim() == p1.city.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;
if (tel2.isNotEmpty) {
body['co_parent_telephone'] = tel2;
}
if (!sameAddr) {
_putIfNonEmpty(body, 'co_parent_adresse', p2.address.trim());
_putIfNonEmpty(body, 'co_parent_code_postal', p2.postalCode.trim());
_putIfNonEmpty(body, 'co_parent_ville', p2.city.trim());
}
}
if (d.motivationText.trim().isNotEmpty) {
body['presentation_dossier'] = d.motivationText.trim();
}
body['enfants'] = d.children.asMap().entries.map((e) {
return _childToJson(e.value, e.key, d.parent1.lastName.trim());
}).toList();
return body;
}
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
};
final prenom = c.firstName.trim();
if (prenom.length >= 2) {
map['prenom'] = prenom;
}
final nom = c.lastName.trim();
if (nom.length >= 2) {
map['nom'] = nom;
} else if (parentNom.length >= 2) {
map['nom'] = parentNom;
}
if (c.isUnbornChild) {
final due = _ddMmYyyyToIso(c.dob);
if (due != null) {
map['date_previsionnelle_naissance'] = due;
}
} else {
final birth = _ddMmYyyyToIso(c.dob);
if (birth != null) {
map['date_naissance'] = birth;
}
}
final photo = _childPhotoBase64(c, index, prenom);
if (photo != null) {
map['photo_base64'] = photo.$1;
map['photo_filename'] = photo.$2;
}
return map;
}
/// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible.
static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) {
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<String, dynamic> m, String key, String value) {
if (value.isNotEmpty) {
m[key] = value;
}
}
static String _normalizePhone(String raw) {
var s = raw.replaceAll(RegExp(r'\s'), '');
if (s.startsWith('0033')) {
s = '+33${s.substring(4)}';
}
return s;
}
/// `jj/mm/aaaa` `aaaa-mm-jj`, ou `null` si invalide.
static String? _ddMmYyyyToIso(String ddMmYyyy) {
if (ddMmYyyy.isEmpty) return null;
final parts = ddMmYyyy.split('/');
if (parts.length != 3) return null;
final day = int.tryParse(parts[0]);
final month = int.tryParse(parts[1]);
final year = int.tryParse(parts[2]);
if (day == null || month == null || year == null) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
}

View File

@ -8,127 +8,46 @@ 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) { if (raw.trim().isEmpty) return raw;
return raw;
}
final normalized = normalizePhone(raw); final normalized = normalizePhone(raw);
if (normalized.isEmpty) { if (normalized.isEmpty) return raw;
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 < normalizedDigits.length; i++) { for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) { if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(' '); buffer.write(normalized[i]);
}
buffer.write(normalizedDigits[i]);
} }
return buffer.toString(); return buffer.toString();
} }
int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) { /// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
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,
) { ) {
var digits = newValue.text.replaceAll(RegExp(r'\D'), ''); final 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 normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final formatted = formatFrenchPhoneDigits(normalized); 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();
// Conserver la position du curseur : compter les chiffres avant la sélection
final sel = newValue.selection; final sel = newValue.selection;
var digitsBeforeCursor = newValue.text final 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;
if (didPrependZero) { final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length); final clampedOffset = newOffset.clamp(0, formatted.length);
}
final clampedOffset =
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
@ -136,10 +55,3 @@ 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

@ -1,19 +0,0 @@
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,7 +1,6 @@
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é.
@ -183,9 +182,6 @@ 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(
@ -195,17 +191,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final t = c.text.trim(); final t = c.text.trim();
if (t.isEmpty) { if (t.isNotEmpty) Navigator.pop(ctx, t);
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,9 +482,6 @@ 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;
@ -724,7 +721,11 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
TextField( TextField(
controller: _ligneFixeCtrl, controller: _ligneFixeCtrl,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters, inputFormatters: [
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

@ -8,8 +8,6 @@ class AppCustomCheckbox extends StatelessWidget {
final double checkboxSize; final double checkboxSize;
final double checkmarkSizeFactor; final double checkmarkSizeFactor;
final double fontSize; final double fontSize;
/// Survol (desktop) ou appui long (mobile) pour afficher un texte daide.
final String? tooltip;
const AppCustomCheckbox({ const AppCustomCheckbox({
super.key, super.key,
@ -19,17 +17,13 @@ class AppCustomCheckbox extends StatelessWidget {
this.checkboxSize = 20.0, this.checkboxSize = 20.0,
this.checkmarkSizeFactor = 1.4, this.checkmarkSizeFactor = 1.4,
this.fontSize = 16.0, this.fontSize = 16.0,
this.tooltip,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return GestureDetector(
mainAxisSize: MainAxisSize.min, onTap: () => onChanged(!value), // Inverse la valeur au clic
children: [ behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable
GestureDetector(
onTap: () => onChanged(!value),
behavior: HitTestBehavior.opaque,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -55,31 +49,16 @@ class AppCustomCheckbox extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
// Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: GoogleFonts.merienda(fontSize: fontSize), style: GoogleFonts.merienda(fontSize: fontSize),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis, // Gérer le texte long
), ),
), ),
], ],
), ),
),
if (tooltip != null && tooltip!.trim().isNotEmpty) ...[
const SizedBox(width: 6),
Tooltip(
message: tooltip!.trim(),
waitDuration: const Duration(milliseconds: 300),
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 6),
child: Icon(
Icons.info_outline,
size: fontSize * 1.2,
color: Colors.black54,
),
),
],
],
); );
} }
} }

View File

@ -1,41 +1,14 @@
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';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart'; import 'hover_relief_widget.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
const String _photoConsentTooltip =
'Obligatoire : cochez cette case pour autoriser lutilisation de la photo de lenfant.\n'
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.';
/// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
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
@ -43,14 +16,11 @@ class ChildCardWidget extends StatefulWidget {
final ChildData childData; final ChildData childData;
final int childIndex; final int childIndex;
final VoidCallback onPickImage; final VoidCallback onPickImage;
/// Retire la photo sélectionnée (placeholder à la place).
final VoidCallback onClearImage;
final VoidCallback onDateSelect; final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged; final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged; final ValueChanged<String> onLastNameChanged;
/// `H`, `F` ou `Autre` (API). « Inconnu » à lUI = `Autre`.
final ValueChanged<String> onGenreChanged;
final ValueChanged<bool> onTogglePhotoConsent; final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn; final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove; final VoidCallback onRemove;
final bool canBeRemoved; final bool canBeRemoved;
@ -62,12 +32,11 @@ class ChildCardWidget extends StatefulWidget {
required this.childData, required this.childData,
required this.childIndex, required this.childIndex,
required this.onPickImage, required this.onPickImage,
required this.onClearImage,
required this.onDateSelect, required this.onDateSelect,
required this.onFirstNameChanged, required this.onFirstNameChanged,
required this.onLastNameChanged, required this.onLastNameChanged,
required this.onGenreChanged,
required this.onTogglePhotoConsent, required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn, required this.onToggleIsUnborn,
required this.onRemove, required this.onRemove,
required this.canBeRemoved, required this.canBeRemoved,
@ -80,24 +49,10 @@ class ChildCardWidget extends StatefulWidget {
} }
class _ChildCardWidgetState extends State<ChildCardWidget> { class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG portrait ~1:2 ; même idée quavant #78 : 345×1,1 pour 200 px de côté).
static double _desktopEditableCardWidth({
required double photoSide,
required double maxWidth,
required double scaleFactor,
}) {
final fromPhoto = photoSide * (345.0 * 1.1 / 200.0);
final minForFields = 300.0 + 44.0 * scaleFactor;
return math.min(maxWidth, math.max(minForFields, fromPhoto));
}
late TextEditingController _firstNameController; late TextEditingController _firstNameController;
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _dobController; late TextEditingController _dobController;
FocusNode? _firstNameFocus;
FocusNode? _lastNameFocus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -110,38 +65,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text)); _firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text)); _lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici // Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
if (widget.mode == DisplayMode.editable) {
_firstNameFocus = FocusNode();
_lastNameFocus = FocusNode();
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_lastNameFocus!.addListener(_onLastNameFocusChange);
}
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
} }
@override @override
@ -162,10 +85,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
@override @override
void dispose() { void dispose() {
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.dispose();
_lastNameFocus?.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_dobController.dispose(); _dobController.dispose();
@ -191,25 +110,16 @@ 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);
final Color initialPhotoShadow = final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow); final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
final Color hoverPhotoShadow =
Color.alphaBlend(Colors.black.withValues(alpha: 0.32), baseCardColorForShadow);
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile
? double.infinity
: _desktopEditableCardWidth(
photoSide: photoSide,
maxWidth: screenSize.width * 0.92,
scaleFactor: scaleFactor,
);
return Container( return Container(
width: editableCardWidth, width: config.isMobile ? double.infinity : screenSize.width * 0.6,
// On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes // On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
// height: config.isMobile ? null : 600.0 * scaleFactor, // height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor), padding: EdgeInsets.all(22.0 * scaleFactor),
@ -222,20 +132,24 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildEditablePhotoArea( // ... (contenu existant)
context: context, HoverReliefWidget(
config: config, onPressed: config.isReadonly ? null : widget.onPickImage,
scaleFactor: scaleFactor, borderRadius: BorderRadius.circular(10),
photoSide: photoSide, initialShadowColor: initialPhotoShadow,
childData: widget.childData, hoverShadowColor: hoverPhotoShadow,
initialPhotoShadow: initialPhotoShadow, child: SizedBox(
hoverPhotoShadow: hoverPhotoShadow, height: 200.0 * (config.isMobile ? 0.8 : 1.0),
width: 200.0 * (config.isMobile ? 0.8 : 1.0),
child: Center(
child: Padding(
padding: EdgeInsets.all(5.0 * scaleFactor),
child: currentChildImage != null
? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
), ),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: config.isMobile ? 13.0 : 16.0,
), ),
SizedBox(height: 10.0 * scaleFactor), SizedBox(height: 10.0 * scaleFactor),
Row( Row(
@ -259,16 +173,13 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
], ],
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildGenreSegment(context, config, scaleFactor),
SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
config: config, config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
label: 'Prénom', label: 'Prénom',
controller: _firstNameController, controller: _firstNameController,
hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom', hint: 'Facultatif si à naître',
isRequired: !widget.childData.isUnbornChild, isRequired: !widget.childData.isUnbornChild,
focusNode: _firstNameFocus,
), ),
SizedBox(height: 5.0 * scaleFactor), SizedBox(height: 5.0 * scaleFactor),
_buildField( _buildField(
@ -277,7 +188,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
label: 'Nom', label: 'Nom',
controller: _lastNameController, controller: _lastNameController,
hint: 'Nom de l\'enfant', hint: 'Nom de l\'enfant',
focusNode: _lastNameFocus,
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
@ -290,6 +200,27 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: config.isReadonly ? null : widget.onDateSelect, onTap: config.isReadonly ? null : widget.onDateSelect,
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
), ),
SizedBox(height: 10.0 * scaleFactor),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
SizedBox(height: 5.0 * scaleFactor),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
],
),
], ],
), ),
if (widget.canBeRemoved && !config.isReadonly) if (widget.canBeRemoved && !config.isReadonly)
@ -299,7 +230,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: widget.onRemove, onTap: widget.onRemove,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Image.asset( child: Image.asset(
'assets/images/cross.png', 'assets/images/red_cross2.png',
width: config.isMobile ? 30 : 36, width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36,
fit: BoxFit.contain, fit: BoxFit.contain,
@ -321,96 +252,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor).
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canInteract ? widget.onPickImage : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
// Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
width: photoSide,
height: photoSide,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && canInteract)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onClearImage,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: config.isMobile ? 26 : 30,
height: config.isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal) /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
@ -426,6 +267,7 @@ 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(
@ -468,14 +310,11 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// PHOTO (1/3) + consentement sous la photo // PHOTO (1/3)
Expanded( Expanded(
flex: 1, flex: 1,
child: Center( child: Center(
child: Column( child: AspectRatio(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -490,20 +329,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: _hasChildPhoto(widget.childData) child: currentChildImage != null
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) ? (kIsWeb
? 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),
), ),
), ),
), ),
const SizedBox(height: 12),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 16.0,
),
],
),
), ),
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
@ -523,14 +356,35 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _dobController.text
), ),
const SizedBox(height: 12),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
], ],
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 18),
// Consentements
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
const SizedBox(width: 32),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
],
),
], ],
), ),
), ),
@ -542,6 +396,8 @@ 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
@ -596,18 +452,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
child: _hasChildPhoto(widget.childData) child: currentChildImage != null
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) ? (kIsWeb
? 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),
), ),
), ),
), ),
const SizedBox(height: 10),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 14.0,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Champs // Champs
@ -619,8 +471,37 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _dobController.text
), ),
const SizedBox(height: 16),
// Consentements
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), Row(
children: [
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
],
),
], ],
), ),
), ),
@ -644,111 +525,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
Widget _buildPhotoConsentRow({
required BuildContext context,
required DisplayConfig config,
required double labelFontSize,
}) {
final readonly = config.isReadonly;
final primary = Theme.of(context).colorScheme.primary;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Checkbox(
value: widget.childData.photoConsent,
onChanged: readonly
? null
: (v) {
if (v != null) widget.onTogglePhotoConsent(v);
},
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
activeColor: primary,
),
Flexible(
child: Text(
'Consentement photo',
style: GoogleFonts.merienda(fontSize: labelFontSize),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Tooltip(
message: _photoConsentTooltip,
waitDuration: const Duration(milliseconds: 300),
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 6),
child: Icon(
Icons.info_outline,
size: labelFontSize * 1.2,
color: Colors.black54,
),
),
],
);
}
static String _genreDisplayLabel(String genre) {
switch (genre) {
case 'F':
return 'Fille';
case 'H':
return 'Garçon';
case 'Autre':
return 'Inconnu';
default:
return '';
}
}
/// Fille / Garçon ; « Inconnu » (`Autre`) uniquement si enfant à naître.
Widget _buildGenreSegment(BuildContext context, DisplayConfig config, double scaleFactor) {
if (config.isReadonly) {
return const SizedBox.shrink();
}
final selected = widget.childData.genre;
final isUnborn = widget.childData.isUnbornChild;
final fontSize = config.isMobile ? 13.0 : 14.0 * scaleFactor;
final padV = config.isMobile ? 6.0 : 8.0 * scaleFactor;
Widget seg(String label, String api) {
final on = selected == api;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: OutlinedButton(
onPressed: () => widget.onGenreChanged(api),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: padV),
visualDensity: VisualDensity.compact,
backgroundColor: on ? Colors.black.withValues(alpha: 0.08) : Colors.transparent,
foregroundColor: Colors.black87,
side: BorderSide(
width: on ? 2 : 1,
color: on ? Theme.of(context).primaryColor : Colors.black38,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
label,
style: GoogleFonts.merienda(fontSize: fontSize, fontWeight: on ? FontWeight.w700 : FontWeight.w500),
textAlign: TextAlign.center,
),
),
),
);
}
return Row(
children: [
seg('Fille', 'F'),
seg('Garçon', 'H'),
if (isUnborn) seg('Inconnu', 'Autre'),
],
);
}
/// Helper pour champ Readonly style "Beige" /// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) { Widget _buildReadonlyField(String label, String value) {
return Column( return Column(
@ -790,7 +566,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
bool readOnly = false, bool readOnly = false,
VoidCallback? onTap, VoidCallback? onTap,
IconData? suffixIcon, IconData? suffixIcon,
FocusNode? focusNode,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -801,7 +576,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
} else { } else {
return CustomAppTextField( return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
isRequired: isRequired, isRequired: isRequired,

View File

@ -32,9 +32,6 @@ 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,
@ -60,9 +57,6 @@ 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
@ -84,13 +78,9 @@ 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,
@ -101,18 +91,16 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
widget.labelText, widget.labelText,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.labelFontSize, fontSize: widget.labelFontSize,
color: labelColor, color: Colors.black87,
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(
@ -124,19 +112,11 @@ 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,
@ -146,7 +126,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: Colors.black87), color: widget.enabled ? Colors.black87 : Colors.grey),
validator: widget.validator ?? validator: widget.validator ??
(value) { (value) {
if (!widget.enabled || widget.readOnly) return null; if (!widget.enabled || widget.readOnly) return null;
@ -160,16 +140,9 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
hintText: widget.hintText, hintText: widget.hintText,
hintStyle: GoogleFonts.merienda( hintStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize, fontSize: widget.inputFontSize,
color: hintColor), color: Colors.black54.withOpacity(0.7)),
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),
@ -178,11 +151,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

@ -1,219 +0,0 @@
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

@ -1,127 +0,0 @@
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

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

View File

@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package: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;
@ -79,19 +76,6 @@ class PersonalInfoFormScreen extends StatefulWidget {
} }
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> { class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// Ordre de tabulation explicite (étape 1 / parent 2, etc.)
static const double _focusSecondPersonToggle = 1;
static const double _focusSameAddressToggle = 2;
static const double _focusLastName = 10;
static const double _focusFirstName = 11;
static const double _focusPhone = 12;
static const double _focusEmail = 13;
static const double _focusAddress = 14;
static const double _focusPostal = 15;
static const double _focusCity = 16;
static const double _focusNavPrevious = 100;
static const double _focusNavNext = 101;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _firstNameController; late TextEditingController _firstNameController;
@ -105,22 +89,13 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
bool _sameAddress = false; bool _sameAddress = false;
bool _fieldsEnabled = true; bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_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( _emailController = TextEditingController(text: widget.initialData.email);
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);
@ -134,147 +109,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_sameAddress = widget.initialSameAddress ?? false; _sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields(); _updateAddressFields();
} }
if (widget.mode == DisplayMode.editable) {
_lastNameFocus = FocusNode();
_firstNameFocus = FocusNode();
_cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
}
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onCityFocusChange() {
if (_cityFocus == null || _cityFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_cityController);
}
void _onEmailFocusChange() {
if (_emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
// Pas derreur « obligatoire » au blur si le champ est encore vide :
// évite un message dès lactivation du parent 2 (focus / rebuild) et
// la soumission du formulaire valide toujours.
if (normalizeEmailText(_emailController.text).isEmpty) {
return;
}
_emailFormKey.currentState?.validate();
});
}
String? _validateRequiredFrenchPhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String? _validateRequiredEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire.';
}
return validateEmail(value, allowEmpty: true);
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredFrenchPhone(value);
}
String? _validateEmailIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredEmail(value);
}
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
String? _validateAddressLineIfManualEntry(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return null;
}
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
String? _validatePostalCodeField(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
return validateFrenchPostalCode(value, allowEmpty: false);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
} }
@override @override
void dispose() { void dispose() {
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange);
_emailFocus?.removeListener(_onEmailFocusChange);
_lastNameFocus?.dispose();
_firstNameFocus?.dispose();
_cityFocus?.dispose();
_emailFocus?.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
@ -293,50 +131,13 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
} }
} }
Widget _wrapScrollBodyIfEditable({
required DisplayConfig config,
required Widget child,
}) {
if (!config.isEditable) return child;
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: child,
);
}
Widget _orderedFocus({
required bool enabled,
required double order,
required Widget child,
}) {
if (!enabled) return child;
return FocusTraversalOrder(
order: NumericFocusOrder(order),
child: child,
);
}
void _handleSubmit() { void _handleSubmit() {
if (widget.mode == DisplayMode.editable) {
_applyPersonNameFormat(_lastNameController);
_applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController);
}
// 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: normalizeEmailText(_emailController.text), email: _emailController.text,
address: _addressController.text, address: _addressController.text,
postalCode: _postalCodeController.text, postalCode: _postalCodeController.text,
city: _cityController.text, city: _cityController.text,
@ -368,8 +169,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0), padding: const EdgeInsets.symmetric(vertical: 40.0),
child: _wrapScrollBodyIfEditable(
config: config,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -392,18 +191,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
SizedBox(height: config.isMobile ? 16 : 30), SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize), _buildCard(context, config, screenSize),
// Boutons mobile sous la carte (dans le scroll)
if (config.isMobile) ...[ if (config.isMobile) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Padding( Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05, horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté)
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavPrevious,
child: HoverReliefWidget( child: HoverReliefWidget(
child: CustomNavigationButton( child: CustomNavigationButton(
text: 'Précédent', text: 'Précédent',
@ -421,12 +219,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
), const SizedBox(width: 16), // Écart entre les boutons
const SizedBox(width: 16),
Expanded( Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavNext,
child: HoverReliefWidget( child: HoverReliefWidget(
child: CustomNavigationButton( child: CustomNavigationButton(
text: 'Suivant', text: 'Suivant',
@ -438,7 +232,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -448,7 +241,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
), ),
), ),
), ),
),
// Chevrons de navigation (desktop uniquement) // Chevrons de navigation (desktop uniquement)
if (!config.isMobile) ...[ if (!config.isMobile) ...[
Positioned( Positioned(
@ -730,10 +522,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
_orderedFocus( Transform.scale(
enabled: config.isEditable,
order: _focusSecondPersonToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0, scale: config.isMobile ? 0.85 : 1.0,
child: Switch( child: Switch(
value: _hasSecondPerson, value: _hasSecondPerson,
@ -741,25 +530,11 @@ 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,
), ),
), ),
),
], ],
); );
} }
@ -783,10 +558,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
_orderedFocus( Transform.scale(
enabled: config.isEditable,
order: _focusSameAddressToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0, scale: config.isMobile ? 0.85 : 1.0,
child: Switch( child: Switch(
value: _sameAddress, value: _sameAddress,
@ -799,7 +571,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
), ),
), ),
),
], ],
); );
} }
@ -835,8 +606,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -847,8 +616,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
), ),
], ],
@ -866,9 +633,7 @@ 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: frenchPhoneInputFormatters, inputFormatters: _phoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -880,11 +645,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
), ),
], ],
@ -898,10 +658,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
@ -914,12 +670,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: '5 chiffres', hint: 'Code postal',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -931,11 +684,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
), ),
], ],
@ -1104,8 +852,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1116,8 +862,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1129,9 +873,7 @@ 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: frenchPhoneInputFormatters, inputFormatters: _phoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1143,11 +885,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1158,10 +895,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1170,12 +903,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: '5 chiffres', hint: 'Code postal',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1186,11 +916,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
], ],
); );
@ -1205,10 +930,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
TextInputType? keyboardType, TextInputType? keyboardType,
bool enabled = true, bool enabled = true,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
double? focusOrder,
FocusNode? focusNode,
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)
@ -1221,12 +942,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue, value: displayValue,
); );
} else { } else {
final effectiveKeyboardType = keyboardType ?? TextInputType.text; // Mode éditable : style adapté mobile/desktop
final isEmail = effectiveKeyboardType == TextInputType.emailAddress; return CustomAppTextField(
Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige, style: CustomAppTextFieldStyle.beige,
@ -1234,26 +952,19 @@ 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: effectiveKeyboardType, keyboardType: keyboardType ?? TextInputType.text,
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) {
field = FocusTraversalOrder(
order: NumericFocusOrder(focusOrder),
child: field,
); );
} }
return field;
}
} }
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) {