Compare commits
No commits in common. "bf05b1d7d7789d15d47a9e46b19599932b2f116e" and "0a07fade40ff7beece240f88bcd6d21b6d6693f2" have entirely different histories.
bf05b1d7d7
...
0a07fade40
@ -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
|
||||||
|
|||||||
@ -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.
|
|
||||||
* L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade.
|
|
||||||
*/
|
|
||||||
async sendParentRegistrationPendingEmail(
|
|
||||||
to: string,
|
|
||||||
prenom: string,
|
|
||||||
nom: string,
|
|
||||||
numeroDossier: string,
|
|
||||||
): Promise<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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
|
|
||||||
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 lorsqu’il aura été 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 été 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
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 |
@ -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 à l’envoi 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
|
||||||
|
|||||||
@ -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'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 'L’adresse e-mail n’est 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(
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
70
frontend/lib/utils/data_generator.dart
Normal file
70
frontend/lib/utils/data_generator.dart
Normal 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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// Longueur maximale d’une adresse e-mail (RFC 5321).
|
|
||||||
const int kEmailMaxLength = 254;
|
|
||||||
|
|
||||||
/// Trim + minuscules (usage à la perte de focus et avant validation côté API).
|
|
||||||
String normalizeEmailText(String raw) {
|
|
||||||
return raw.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Motif volontairement simple pour l’UI (pas une validation RFC complète).
|
|
||||||
/// Local + @ + domaine avec au moins un point ; pas d’espaces.
|
|
||||||
final RegExp kAppEmailPattern = RegExp(
|
|
||||||
r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Indique si [trimmed] est un e-mail plausible (insensible à la casse).
|
|
||||||
bool isValidEmailFormat(String trimmed) {
|
|
||||||
final s = normalizeEmailText(trimmed);
|
|
||||||
if (s.isEmpty || s.length > kEmailMaxLength) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return kAppEmailPattern.hasMatch(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validation centralisée pour les champs e-mail.
|
|
||||||
///
|
|
||||||
/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée.
|
|
||||||
String? validateEmail(String? raw, {bool allowEmpty = false}) {
|
|
||||||
final s = normalizeEmailText(raw ?? '');
|
|
||||||
if (s.isEmpty) {
|
|
||||||
return allowEmpty ? null : 'L’adresse e-mail est obligatoire.';
|
|
||||||
}
|
|
||||||
if (s.length > kEmailMaxLength) {
|
|
||||||
return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).';
|
|
||||||
}
|
|
||||||
if (!kAppEmailPattern.hasMatch(s)) {
|
|
||||||
return 'Le format de l’adresse e-mail est incorrect.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]).
|
|
||||||
class EmailMaxLengthFormatter extends TextInputFormatter {
|
|
||||||
const EmailMaxLengthFormatter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
if (newValue.text.length <= kEmailMaxLength) {
|
|
||||||
return newValue;
|
|
||||||
}
|
|
||||||
return oldValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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 'L’email du parent principal est requis.';
|
|
||||||
}
|
|
||||||
if (!isValidEmailFormat(p1Email)) {
|
|
||||||
return 'L’email du parent principal n’est pas au bon format.';
|
|
||||||
}
|
|
||||||
if (p1.firstName.trim().length < 2) {
|
|
||||||
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
|
|
||||||
}
|
|
||||||
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 n’est 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 'L’email du co-parent n’est pas au bon format.';
|
|
||||||
}
|
|
||||||
if (p2Email == p1Email) {
|
|
||||||
return 'L’email du co-parent doit être différent de celui du parent principal.';
|
|
||||||
}
|
|
||||||
final tel2 = _normalizePhone(p2.phone);
|
|
||||||
if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) {
|
|
||||||
return 'Le numéro de téléphone du co-parent n’est 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')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 1–9 (format national).
|
|
||||||
///
|
|
||||||
/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.).
|
|
||||||
/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée.
|
|
||||||
String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) {
|
|
||||||
final trimmed = raw?.trim() ?? '';
|
|
||||||
if (trimmed.isEmpty) {
|
|
||||||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
|
||||||
}
|
|
||||||
final digits = normalizePhone(trimmed);
|
|
||||||
if (digits.isEmpty) {
|
|
||||||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
|
||||||
}
|
|
||||||
if (!frenchNationalPhoneStartsWithZero(digits)) {
|
|
||||||
return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).';
|
|
||||||
}
|
|
||||||
if (digits.length < 10) {
|
|
||||||
return 'Le numéro doit contenir 10 chiffres.';
|
|
||||||
}
|
|
||||||
if (digits.length > 10) {
|
|
||||||
return 'Le numéro ne peut pas dépasser 10 chiffres.';
|
|
||||||
}
|
|
||||||
if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) {
|
|
||||||
return 'Numéro de téléphone invalide.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
|
/// 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 l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
|
|
||||||
/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté.
|
|
||||||
class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
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(),
|
|
||||||
];
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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'),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 d’aide.
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 l’utilisation de la photo de l’enfant.\n'
|
|
||||||
'Suivi du dossier et organisation de l’accueil (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 » à l’UI = `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 qu’avant #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 qu’avant (~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 : l’arrondi 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,
|
||||||
|
|||||||
@ -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 d’erreur du
|
|
||||||
// validateur s’affiche 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 1–7, etc.) + validation optionnelle.
|
|
||||||
///
|
|
||||||
/// Préférer ce widget ou [frenchPhoneInputFormatters] + [validateFrenchNationalPhone] pour tout nouveau formulaire.
|
|
||||||
class FrenchPhoneTextFormField extends StatelessWidget {
|
|
||||||
const FrenchPhoneTextFormField({
|
|
||||||
super.key,
|
|
||||||
required this.controller,
|
|
||||||
this.decoration,
|
|
||||||
this.label,
|
|
||||||
this.hint,
|
|
||||||
this.allowEmpty = false,
|
|
||||||
this.readOnly = false,
|
|
||||||
this.focusNode,
|
|
||||||
this.autovalidateMode,
|
|
||||||
this.validator,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TextEditingController controller;
|
|
||||||
final InputDecoration? decoration;
|
|
||||||
final String? label;
|
|
||||||
final String? hint;
|
|
||||||
final bool allowEmpty;
|
|
||||||
final bool readOnly;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final AutovalidateMode? autovalidateMode;
|
|
||||||
final String? Function(String?)? validator;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final deco = decoration ??
|
|
||||||
InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
hintText: hint,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return TextFormField(
|
|
||||||
controller: controller,
|
|
||||||
focusNode: focusNode,
|
|
||||||
readOnly: readOnly,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
inputFormatters: frenchPhoneInputFormatters,
|
|
||||||
autovalidateMode: autovalidateMode,
|
|
||||||
decoration: deco,
|
|
||||||
validator: readOnly
|
|
||||||
? null
|
|
||||||
: (validator ??
|
|
||||||
(value) => validateFrenchNationalPhone(value, allowEmpty: allowEmpty)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Même logique que [FrenchPhoneTextFormField], avec le rendu [CustomAppTextField] (inscription, cartes, etc.).
|
|
||||||
class FrenchPhoneCustomTextField extends StatelessWidget {
|
|
||||||
const FrenchPhoneCustomTextField({
|
|
||||||
super.key,
|
|
||||||
required this.controller,
|
|
||||||
required this.labelText,
|
|
||||||
this.hintText = '',
|
|
||||||
this.focusNode,
|
|
||||||
this.fieldWidth = double.infinity,
|
|
||||||
this.fieldHeight = 53.0,
|
|
||||||
this.labelFontSize = 22.0,
|
|
||||||
this.inputFontSize = 20.0,
|
|
||||||
this.enabled = true,
|
|
||||||
this.readOnly = false,
|
|
||||||
this.allowEmpty = false,
|
|
||||||
this.style = CustomAppTextFieldStyle.beige,
|
|
||||||
this.validator,
|
|
||||||
this.suffixIcon,
|
|
||||||
this.onTap,
|
|
||||||
this.autofillHints,
|
|
||||||
this.textInputAction,
|
|
||||||
this.onFieldSubmitted,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TextEditingController controller;
|
|
||||||
final String labelText;
|
|
||||||
final String hintText;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final double fieldWidth;
|
|
||||||
final double fieldHeight;
|
|
||||||
final double labelFontSize;
|
|
||||||
final double inputFontSize;
|
|
||||||
final bool enabled;
|
|
||||||
final bool readOnly;
|
|
||||||
final bool allowEmpty;
|
|
||||||
final CustomAppTextFieldStyle style;
|
|
||||||
final String? Function(String?)? validator;
|
|
||||||
final IconData? suffixIcon;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final Iterable<String>? autofillHints;
|
|
||||||
final TextInputAction? textInputAction;
|
|
||||||
final ValueChanged<String>? onFieldSubmitted;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CustomAppTextField(
|
|
||||||
controller: controller,
|
|
||||||
focusNode: focusNode,
|
|
||||||
labelText: labelText,
|
|
||||||
hintText: hintText,
|
|
||||||
style: style,
|
|
||||||
fieldWidth: fieldWidth,
|
|
||||||
fieldHeight: fieldHeight,
|
|
||||||
labelFontSize: labelFontSize,
|
|
||||||
inputFontSize: inputFontSize,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
enabled: enabled,
|
|
||||||
readOnly: readOnly,
|
|
||||||
onTap: onTap,
|
|
||||||
suffixIcon: suffixIcon,
|
|
||||||
inputFormatters: frenchPhoneInputFormatters,
|
|
||||||
autofillHints: autofillHints,
|
|
||||||
textInputAction: textInputAction,
|
|
||||||
onFieldSubmitted: onFieldSubmitted,
|
|
||||||
validator: validator ??
|
|
||||||
((v) => validateFrenchNationalPhone(v, allowEmpty: allowEmpty)),
|
|
||||||
isRequired: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 d’erreur « obligatoire » au blur si le champ est encore vide :
|
|
||||||
// évite un message dès l’activation du parent 2 (focus / rebuild) et
|
|
||||||
// la soumission du formulaire valide toujours.
|
|
||||||
if (normalizeEmailText(_emailController.text).isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_emailFormKey.currentState?.validate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateRequiredFrenchPhone(String? value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'Ce champ est obligatoire';
|
|
||||||
}
|
|
||||||
return validateFrenchNationalPhone(value, allowEmpty: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateRequiredEmail(String? value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'Ce champ est obligatoire.';
|
|
||||||
}
|
|
||||||
return validateEmail(value, allowEmpty: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
|
|
||||||
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
|
|
||||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return _validateRequiredFrenchPhone(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmailIfSecondParentNeeded(String? value) {
|
|
||||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return _validateRequiredEmail(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
|
|
||||||
String? _validateAddressLineIfManualEntry(String? value) {
|
|
||||||
if (!_fieldsEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (widget.showSameAddressCheckbox && _sameAddress) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'Ce champ est obligatoire';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
|
|
||||||
String? _validatePostalCodeField(String? value) {
|
|
||||||
if (!_fieldsEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (widget.showSameAddressCheckbox && _sameAddress) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return validateFrenchPostalCode(value, allowEmpty: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyPersonNameFormat(TextEditingController controller) {
|
|
||||||
final formatted = formatPersonNameCase(controller.text);
|
|
||||||
if (formatted == controller.text) {
|
|
||||||
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 l’utilisateur a déjà recoché Parent 2, ne pas valider :
|
|
||||||
// sinon des champs vides affichent une erreur tout de suite.
|
|
||||||
if (!mounted || _hasSecondPerson) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_formKey.currentState?.validate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
activeColor: Theme.of(context).primaryColor,
|
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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user