feat(inscription): email accusé réception, photos enfants, formulaires identité
- Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API - Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés - Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field Made-with: Cursor
This commit is contained in:
parent
110240b682
commit
bf05b1d7d7
@ -22,5 +22,8 @@ JWT_EXPIRATION_TIME=7d
|
||||
# Environnement
|
||||
NODE_ENV=development
|
||||
|
||||
# Photos (inscription). Defaut : ./uploads/photos sous le backend. Docker : UPLOAD_PHOTOS_DIR=/app/uploads/photos
|
||||
# UPLOAD_PHOTOS_DIR=
|
||||
|
||||
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
|
||||
# LOG_API_REQUESTS=true
|
||||
|
||||
@ -98,6 +98,44 @@ export class MailService {
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accusé de réception inscription parent : demande enregistrée + n° de dossier.
|
||||
* L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade.
|
||||
*/
|
||||
async sendParentRegistrationPendingEmail(
|
||||
to: string,
|
||||
prenom: string,
|
||||
nom: string,
|
||||
numeroDossier: string,
|
||||
): Promise<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).
|
||||
* Ticket #110 – Refus sans suppression
|
||||
|
||||
@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AppConfigModule } from 'src/modules/config';
|
||||
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||
forwardRef(() => UserModule),
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
NumeroDossierModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@ -28,6 +28,7 @@ import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -36,6 +37,7 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly numeroDossierService: NumeroDossierService,
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepo: Repository<Parents>,
|
||||
@ -330,12 +332,34 @@ export class AuthService {
|
||||
};
|
||||
});
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[inscrireParentComplet] Échec envoi email de confirmation d'inscription", err);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
parent_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
@ -464,7 +488,9 @@ export class AuthService {
|
||||
const extension = correspondances[1];
|
||||
const tamponImage = Buffer.from(correspondances[2], 'base64');
|
||||
|
||||
const dossierUpload = '/app/uploads/photos';
|
||||
const dossierUpload =
|
||||
(process.env.UPLOAD_PHOTOS_DIR && process.env.UPLOAD_PHOTOS_DIR.trim()) ||
|
||||
path.join(process.cwd(), 'uploads', 'photos');
|
||||
await fs.mkdir(dossierUpload, { recursive: true });
|
||||
|
||||
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||||
|
||||
@ -29,6 +29,7 @@ class ParentData {
|
||||
|
||||
class ChildData {
|
||||
static const Object _unsetImage = Object();
|
||||
static const Object _unsetImageBytes = Object();
|
||||
|
||||
String firstName;
|
||||
String lastName;
|
||||
@ -39,6 +40,8 @@ class ChildData {
|
||||
bool multipleBirth;
|
||||
bool isUnbornChild;
|
||||
File? imageFile;
|
||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||
Uint8List? imageBytes;
|
||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
||||
|
||||
ChildData({
|
||||
@ -50,6 +53,7 @@ class ChildData {
|
||||
this.multipleBirth = false,
|
||||
this.isUnbornChild = false,
|
||||
this.imageFile,
|
||||
this.imageBytes,
|
||||
required this.cardColor, // Rendre requis dans le constructeur
|
||||
});
|
||||
|
||||
@ -62,6 +66,7 @@ class ChildData {
|
||||
bool? multipleBirth,
|
||||
bool? isUnbornChild,
|
||||
Object? imageFile = _unsetImage,
|
||||
Object? imageBytes = _unsetImageBytes,
|
||||
CardColorVertical? cardColor,
|
||||
}) {
|
||||
return ChildData(
|
||||
@ -73,6 +78,8 @@ class ChildData {
|
||||
multipleBirth: multipleBirth ?? this.multipleBirth,
|
||||
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
|
||||
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
|
||||
imageBytes:
|
||||
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
|
||||
cardColor: cardColor ?? this.cardColor,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||
|
||||
class AdminCreateDialog extends StatefulWidget {
|
||||
final AppUser? initialUser;
|
||||
@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final base = _required(value, 'Email');
|
||||
if (base != null) return base;
|
||||
final email = value!.trim();
|
||||
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||
if (!ok) return 'Format email invalide';
|
||||
return null;
|
||||
if (base != null) {
|
||||
return base;
|
||||
}
|
||||
return validateEmail(value, allowEmpty: true);
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
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 {
|
||||
if (_isSubmitting) return;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
}
|
||||
|
||||
Widget _buildEmailField() {
|
||||
return TextFormField(
|
||||
return EmailTextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
label: 'Email',
|
||||
validator: _validateEmail,
|
||||
);
|
||||
}
|
||||
@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
}
|
||||
|
||||
Widget _buildTelephoneField() {
|
||||
return TextFormField(
|
||||
return FrenchPhoneTextFormField(
|
||||
controller: _telephoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) => _required(v, 'Téléphone'),
|
||||
label: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
validator: _validateTelephone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/models/relais_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/relais_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final base = _required(value, 'Email');
|
||||
if (base != null) return base;
|
||||
final email = value!.trim();
|
||||
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||
if (!ok) return 'Format email invalide';
|
||||
return null;
|
||||
if (base != null) {
|
||||
return base;
|
||||
}
|
||||
return validateEmail(value, allowEmpty: true);
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
return null;
|
||||
}
|
||||
final base = _required(value, 'Téléphone');
|
||||
if (base != null) return base;
|
||||
final digits = normalizePhone(value!);
|
||||
if (digits.length != 10) {
|
||||
return 'Le téléphone doit contenir 10 chiffres';
|
||||
if (base != null) {
|
||||
return base;
|
||||
}
|
||||
if (!digits.startsWith('0')) {
|
||||
return 'Le téléphone doit commencer par 0';
|
||||
}
|
||||
return null;
|
||||
return validateFrenchNationalPhone(value, allowEmpty: false);
|
||||
}
|
||||
|
||||
String _toTitleCase(String raw) {
|
||||
@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
}
|
||||
|
||||
Widget _buildEmailField() {
|
||||
return TextFormField(
|
||||
return EmailTextFormField(
|
||||
controller: _emailController,
|
||||
readOnly: widget.readOnly,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
label: 'Email',
|
||||
validator: widget.readOnly ? null : _validateEmail,
|
||||
);
|
||||
}
|
||||
@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
}
|
||||
|
||||
Widget _buildTelephoneField() {
|
||||
return TextFormField(
|
||||
return FrenchPhoneTextFormField(
|
||||
controller: _telephoneController,
|
||||
readOnly: widget.readOnly,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: widget.readOnly
|
||||
? null
|
||||
: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
label: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
validator: widget.readOnly ? null : _validatePhone,
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:p_tits_pas/services/bug_report_service.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_app_text_field.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
@ -21,6 +22,10 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final GlobalKey<FormFieldState<String>> _emailFormKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _emailFocus;
|
||||
late final FocusNode _passwordFocus;
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
@ -36,22 +41,49 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||||
_emailFocus = FocusNode();
|
||||
_passwordFocus = FocusNode();
|
||||
_emailFocus.addListener(_onEmailFocusChange);
|
||||
}
|
||||
|
||||
void _onEmailFocusChange() {
|
||||
if (_emailFocus.hasFocus) {
|
||||
return;
|
||||
}
|
||||
// Reporter au frame suivant : une mise à jour synchrone du controller pendant
|
||||
// un transfert de focus (Tab) peut casser la navigation au clavier.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _emailFocus.hasFocus) {
|
||||
return;
|
||||
}
|
||||
final normalized = normalizeEmailText(_emailController.text);
|
||||
if (normalized != _emailController.text) {
|
||||
_emailController.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
_emailFormKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailFocus.removeListener(_onEmailFocusChange);
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final v = value ?? '';
|
||||
final v = value?.trim() ?? '';
|
||||
if (v.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
return 'Veuillez entrer votre adresse e-mail.';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
if (!isValidEmailFormat(v)) {
|
||||
return 'L’adresse e-mail n’est pas valide.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -205,53 +237,75 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||
child: AutofillGroup(
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Champs côte à côte
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomAppTextField(
|
||||
controller: _emailController,
|
||||
labelText: 'Email',
|
||||
hintText: 'Votre adresse email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: _validateEmail,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 53,
|
||||
fieldWidth: double.infinity,
|
||||
FocusTraversalGroup(
|
||||
policy: OrderedTraversalPolicy(),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusTraversalOrder(
|
||||
order: const NumericFocusOrder(1),
|
||||
child: CustomAppTextField(
|
||||
formFieldKey: _emailFormKey,
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
labelText: 'Email',
|
||||
hintText: 'Votre adresse email',
|
||||
keyboardType:
|
||||
TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
inputFormatters: const [
|
||||
EmailMaxLengthFormatter(),
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) =>
|
||||
_passwordFocus.requestFocus(),
|
||||
validator: _validateEmail,
|
||||
style:
|
||||
CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 53,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: CustomAppTextField(
|
||||
controller: _passwordController,
|
||||
labelText: 'Mot de passe',
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password,
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted:
|
||||
_handlePasswordSubmitted,
|
||||
validator: _validatePassword,
|
||||
style: CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 53,
|
||||
fieldWidth: double.infinity,
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: FocusTraversalOrder(
|
||||
order: const NumericFocusOrder(2),
|
||||
child: CustomAppTextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
labelText: 'Mot de passe',
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password,
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted:
|
||||
_handlePasswordSubmitted,
|
||||
validator: _validatePassword,
|
||||
style:
|
||||
CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 53,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@ -461,46 +515,68 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
horizontal: 24, vertical: 20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppTextField(
|
||||
controller: _emailController,
|
||||
labelText: 'Email',
|
||||
showLabel: false,
|
||||
hintText: 'Votre adresse email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: _validateEmail,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomAppTextField(
|
||||
controller: _passwordController,
|
||||
labelText: 'Mot de passe',
|
||||
showLabel: false,
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: _handlePasswordSubmitted,
|
||||
validator: _validatePassword,
|
||||
style: CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
child: FocusTraversalGroup(
|
||||
policy: OrderedTraversalPolicy(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
FocusTraversalOrder(
|
||||
order: const NumericFocusOrder(1),
|
||||
child: CustomAppTextField(
|
||||
formFieldKey: _emailFormKey,
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
labelText: 'Email',
|
||||
showLabel: false,
|
||||
hintText: 'Votre adresse email',
|
||||
keyboardType:
|
||||
TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
inputFormatters: const [
|
||||
EmailMaxLengthFormatter(),
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) =>
|
||||
_passwordFocus.requestFocus(),
|
||||
validator: _validateEmail,
|
||||
style:
|
||||
CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FocusTraversalOrder(
|
||||
order: const NumericFocusOrder(2),
|
||||
child: CustomAppTextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
labelText: 'Mot de passe',
|
||||
showLabel: false,
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted:
|
||||
_handlePasswordSubmitted,
|
||||
validator: _validatePassword,
|
||||
style:
|
||||
CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
@ -571,6 +647,7 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
||||
child: Wrap(
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io' show File;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/child_card_widget.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
@ -152,10 +153,19 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
if (pickedFile != null) {
|
||||
if (childIndex < registrationData.children.length) {
|
||||
final oldChild = registrationData.children[childIndex];
|
||||
final updatedChild = oldChild.copyWith(imageFile: File(pickedFile.path));
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
if (bytes.isEmpty) return;
|
||||
File? file;
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
final f = File(pickedFile.path);
|
||||
if (await f.exists()) file = f;
|
||||
} catch (_) {}
|
||||
}
|
||||
final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file);
|
||||
registrationData.updateChild(childIndex, updatedChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { print("Erreur image: $e"); }
|
||||
}
|
||||
|
||||
@ -285,13 +295,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
// Générer les cartes enfants
|
||||
for (int index = 0; index < registrationData.children.length; index++) ...[
|
||||
ChildCardWidget(
|
||||
key: ValueKey(registrationData.children[index].hashCode),
|
||||
key: ValueKey('parent_register_child_$index'),
|
||||
childData: registrationData.children[index],
|
||||
childIndex: index,
|
||||
onPickImage: () => _pickImage(index, registrationData),
|
||||
onClearImage: () => setState(() {
|
||||
final c = registrationData.children[index];
|
||||
registrationData.updateChild(index, c.copyWith(imageFile: null));
|
||||
registrationData.updateChild(
|
||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
||||
}),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() {
|
||||
@ -392,13 +403,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 20.0),
|
||||
child: ChildCardWidget(
|
||||
key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
|
||||
key: ValueKey('parent_register_child_$index'),
|
||||
childData: registrationData.children[index],
|
||||
childIndex: index,
|
||||
onPickImage: () => _pickImage(index, registrationData),
|
||||
onClearImage: () => setState(() {
|
||||
final c = registrationData.children[index];
|
||||
registrationData.updateChild(index, c.copyWith(imageFile: null));
|
||||
registrationData.updateChild(
|
||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
||||
}),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() {
|
||||
|
||||
57
frontend/lib/utils/email_utils.dart
Normal file
57
frontend/lib/utils/email_utils.dart
Normal file
@ -0,0 +1,57 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Longueur maximale d’une adresse e-mail (RFC 5321).
|
||||
const int kEmailMaxLength = 254;
|
||||
|
||||
/// Trim + minuscules (usage à la perte de focus et avant validation côté API).
|
||||
String normalizeEmailText(String raw) {
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Motif volontairement simple pour l’UI (pas une validation RFC complète).
|
||||
/// Local + @ + domaine avec au moins un point ; pas d’espaces.
|
||||
final RegExp kAppEmailPattern = RegExp(
|
||||
r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$',
|
||||
);
|
||||
|
||||
/// Indique si [trimmed] est un e-mail plausible (insensible à la casse).
|
||||
bool isValidEmailFormat(String trimmed) {
|
||||
final s = normalizeEmailText(trimmed);
|
||||
if (s.isEmpty || s.length > kEmailMaxLength) {
|
||||
return false;
|
||||
}
|
||||
return kAppEmailPattern.hasMatch(s);
|
||||
}
|
||||
|
||||
/// Validation centralisée pour les champs e-mail.
|
||||
///
|
||||
/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée.
|
||||
String? validateEmail(String? raw, {bool allowEmpty = false}) {
|
||||
final s = normalizeEmailText(raw ?? '');
|
||||
if (s.isEmpty) {
|
||||
return allowEmpty ? null : 'L’adresse e-mail est obligatoire.';
|
||||
}
|
||||
if (s.length > kEmailMaxLength) {
|
||||
return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).';
|
||||
}
|
||||
if (!kAppEmailPattern.hasMatch(s)) {
|
||||
return 'Le format de l’adresse e-mail est incorrect.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]).
|
||||
class EmailMaxLengthFormatter extends TextInputFormatter {
|
||||
const EmailMaxLengthFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (newValue.text.length <= kEmailMaxLength) {
|
||||
return newValue;
|
||||
}
|
||||
return oldValue;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/user_registration_data.dart';
|
||||
import 'email_utils.dart';
|
||||
|
||||
/// Construction du body `POST /auth/register/parent` à partir du state d'inscription.
|
||||
/// Aligné sur [RegisterParentCompletDto] (backend).
|
||||
@ -14,9 +16,13 @@ class ParentRegistrationPayload {
|
||||
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
|
||||
static String? validateForApi(UserRegistrationData d) {
|
||||
final p1 = d.parent1;
|
||||
if (p1.email.trim().isEmpty) {
|
||||
final p1Email = normalizeEmailText(p1.email);
|
||||
if (p1Email.isEmpty) {
|
||||
return 'L’email du parent principal est requis.';
|
||||
}
|
||||
if (!isValidEmailFormat(p1Email)) {
|
||||
return 'L’email du parent principal n’est pas au bon format.';
|
||||
}
|
||||
if (p1.firstName.trim().length < 2) {
|
||||
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
|
||||
}
|
||||
@ -41,12 +47,16 @@ class ParentRegistrationPayload {
|
||||
p2.lastName.trim().isNotEmpty ||
|
||||
p2.phone.trim().isNotEmpty;
|
||||
if (any) {
|
||||
if (p2.email.trim().isEmpty ||
|
||||
final p2Email = normalizeEmailText(p2.email);
|
||||
if (p2Email.isEmpty ||
|
||||
p2.firstName.trim().length < 2 ||
|
||||
p2.lastName.trim().length < 2) {
|
||||
return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).';
|
||||
}
|
||||
if (p2.email.trim().toLowerCase() == p1.email.trim().toLowerCase()) {
|
||||
if (!isValidEmailFormat(p2Email)) {
|
||||
return 'L’email du co-parent n’est pas au bon format.';
|
||||
}
|
||||
if (p2Email == p1Email) {
|
||||
return 'L’email du co-parent doit être différent de celui du parent principal.';
|
||||
}
|
||||
final tel2 = _normalizePhone(p2.phone);
|
||||
@ -114,7 +124,7 @@ class ParentRegistrationPayload {
|
||||
p2.postalCode.trim() == p1.postalCode.trim() &&
|
||||
p2.city.trim() == p1.city.trim();
|
||||
|
||||
body['co_parent_email'] = p2.email.trim();
|
||||
body['co_parent_email'] = normalizeEmailText(p2.email);
|
||||
body['co_parent_prenom'] = p2.firstName.trim();
|
||||
body['co_parent_nom'] = p2.lastName.trim();
|
||||
body['co_parent_meme_adresse'] = sameAddr;
|
||||
@ -142,7 +152,7 @@ class ParentRegistrationPayload {
|
||||
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||
final map = <String, dynamic>{
|
||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||
'grossesse_multiple': false,
|
||||
'grossesse_multiple': c.multipleBirth,
|
||||
};
|
||||
|
||||
final prenom = c.firstName.trim();
|
||||
@ -180,20 +190,40 @@ class ParentRegistrationPayload {
|
||||
|
||||
/// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible.
|
||||
static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) {
|
||||
final file = c.imageFile;
|
||||
if (file == null) return null;
|
||||
try {
|
||||
if (!file.existsSync()) return null;
|
||||
final bytes = file.readAsBytesSync();
|
||||
if (bytes.isEmpty) return null;
|
||||
final b64 = base64Encode(bytes);
|
||||
final safeName = prenom.isNotEmpty
|
||||
? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.jpg'
|
||||
: 'enfant_${index + 1}.jpg';
|
||||
return ('data:image/jpeg;base64,$b64', safeName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
Uint8List? bytes = c.imageBytes;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
final file = c.imageFile;
|
||||
if (file == null) return null;
|
||||
try {
|
||||
if (!file.existsSync()) return null;
|
||||
bytes = file.readAsBytesSync();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (bytes.isEmpty) return null;
|
||||
final b64 = base64Encode(bytes);
|
||||
var mime = 'jpeg';
|
||||
if (bytes.length >= 8) {
|
||||
if (bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
mime = 'png';
|
||||
} else if (bytes[0] == 0xFF && bytes[1] == 0xD8) {
|
||||
mime = 'jpeg';
|
||||
} else if (bytes.length >= 12 &&
|
||||
bytes[0] == 0x52 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46 &&
|
||||
bytes[3] == 0x46) {
|
||||
mime = 'webp';
|
||||
}
|
||||
}
|
||||
final safeName = prenom.isNotEmpty
|
||||
? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.$mime'
|
||||
: 'enfant_${index + 1}.$mime';
|
||||
return ('data:image/$mime;base64,$b64', safeName);
|
||||
}
|
||||
|
||||
static void _putIfNonEmpty(Map<String, dynamic> m, String key, String value) {
|
||||
|
||||
@ -8,46 +8,127 @@ String normalizePhone(String raw) {
|
||||
return digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
}
|
||||
|
||||
/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`.
|
||||
/// Chaîne vide : `true` (pas encore de saisie).
|
||||
bool frenchNationalPhoneStartsWithZero(String digitsOnly) {
|
||||
if (digitsOnly.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return digitsOnly.startsWith('0');
|
||||
}
|
||||
|
||||
/// Validation téléphone France : 10 chiffres, commence par `0`, 2ᵉ chiffre 1–9 (format national).
|
||||
///
|
||||
/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.).
|
||||
/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée.
|
||||
String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) {
|
||||
final trimmed = raw?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
||||
}
|
||||
final digits = normalizePhone(trimmed);
|
||||
if (digits.isEmpty) {
|
||||
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
|
||||
}
|
||||
if (!frenchNationalPhoneStartsWithZero(digits)) {
|
||||
return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).';
|
||||
}
|
||||
if (digits.length < 10) {
|
||||
return 'Le numéro doit contenir 10 chiffres.';
|
||||
}
|
||||
if (digits.length > 10) {
|
||||
return 'Le numéro ne peut pas dépasser 10 chiffres.';
|
||||
}
|
||||
if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) {
|
||||
return 'Numéro de téléphone invalide.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
|
||||
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.).
|
||||
String formatPhoneForDisplay(String raw) {
|
||||
if (raw.trim().isEmpty) return raw;
|
||||
if (raw.trim().isEmpty) {
|
||||
return raw;
|
||||
}
|
||||
final normalized = normalizePhone(raw);
|
||||
if (normalized.isEmpty) return raw;
|
||||
if (normalized.isEmpty) {
|
||||
return raw;
|
||||
}
|
||||
return formatFrenchPhoneDigits(normalized);
|
||||
}
|
||||
|
||||
/// Affiche les chiffres par paires (ex. `0612345678` → `06 12 34 56 78`).
|
||||
String formatFrenchPhoneDigits(String normalizedDigits) {
|
||||
if (normalizedDigits.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < normalized.length; i++) {
|
||||
if (i > 0 && i.isEven) buffer.write(' ');
|
||||
buffer.write(normalized[i]);
|
||||
for (var i = 0; i < normalizedDigits.length; i++) {
|
||||
if (i > 0 && i.isEven) {
|
||||
buffer.write(' ');
|
||||
}
|
||||
buffer.write(normalizedDigits[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
|
||||
int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) {
|
||||
final k = digitCountBeforeCursor.clamp(0, normalized.length);
|
||||
if (k == 0) {
|
||||
return 0;
|
||||
}
|
||||
return formatFrenchPhoneDigits(normalized.substring(0, k)).length;
|
||||
}
|
||||
|
||||
/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres.
|
||||
///
|
||||
/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` → `06`).
|
||||
/// - Si l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
|
||||
/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté.
|
||||
class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||
const FrenchPhoneNumberFormatter();
|
||||
|
||||
static const String _autoPrefixFirstDigits = '1234567';
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < normalized.length; i++) {
|
||||
if (i > 0 && i.isEven) buffer.write(' ');
|
||||
buffer.write(normalized[i]);
|
||||
}
|
||||
final formatted = buffer.toString();
|
||||
var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
var didPrependZero = false;
|
||||
|
||||
if (digits.isNotEmpty) {
|
||||
final first = digits[0];
|
||||
if (first == '8' || first == '9') {
|
||||
return oldValue;
|
||||
}
|
||||
if (first != '0' && _autoPrefixFirstDigits.contains(first)) {
|
||||
digits = '0$digits';
|
||||
didPrependZero = true;
|
||||
if (digits.length > 10) {
|
||||
digits = digits.substring(0, 10);
|
||||
}
|
||||
} else if (first != '0') {
|
||||
return oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
final formatted = formatFrenchPhoneDigits(normalized);
|
||||
|
||||
// Conserver la position du curseur : compter les chiffres avant la sélection
|
||||
final sel = newValue.selection;
|
||||
final digitsBeforeCursor = newValue.text
|
||||
var digitsBeforeCursor = newValue.text
|
||||
.substring(0, sel.start.clamp(0, newValue.text.length))
|
||||
.replaceAll(RegExp(r'\D'), '')
|
||||
.length;
|
||||
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
|
||||
final clampedOffset = newOffset.clamp(0, formatted.length);
|
||||
if (didPrependZero) {
|
||||
digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length);
|
||||
}
|
||||
|
||||
final clampedOffset =
|
||||
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.).
|
||||
final List<TextInputFormatter> frenchPhoneInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
const FrenchPhoneNumberFormatter(),
|
||||
];
|
||||
|
||||
19
frontend/lib/utils/postal_utils.dart
Normal file
19
frontend/lib/utils/postal_utils.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Saisie code postal français : uniquement des chiffres, au plus 5.
|
||||
final List<TextInputFormatter> kFrenchPostalCodeInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(5),
|
||||
];
|
||||
|
||||
/// Valide un code postal français (exactement 5 chiffres).
|
||||
String? validateFrenchPostalCode(String? raw, {bool allowEmpty = false}) {
|
||||
final s = raw?.trim() ?? '';
|
||||
if (s.isEmpty) {
|
||||
return allowEmpty ? null : 'Ce champ est obligatoire';
|
||||
}
|
||||
if (s.length != 5 || int.tryParse(s) == null) {
|
||||
return 'Le code postal doit comporter 5 chiffres.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
|
||||
|
||||
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
||||
@ -182,6 +183,9 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
||||
hintText: 'admin@example.com',
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@ -191,7 +195,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final t = c.text.trim();
|
||||
if (t.isNotEmpty) Navigator.pop(ctx, t);
|
||||
if (t.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final err = validateEmail(t, allowEmpty: true);
|
||||
if (err != null) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(ctx, t);
|
||||
},
|
||||
child: const Text('Envoyer'),
|
||||
),
|
||||
|
||||
@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
||||
if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) {
|
||||
return false;
|
||||
}
|
||||
if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final day in _days) {
|
||||
final ferme = _closedByDay[day] ?? false;
|
||||
@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
||||
TextField(
|
||||
controller: _ligneFixeCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ligne fixe',
|
||||
hintText: '01 23 45 67 89',
|
||||
|
||||
@ -2,7 +2,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:io' show File;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../models/user_registration_data.dart';
|
||||
import '../models/card_assets.dart';
|
||||
@ -20,6 +19,24 @@ const String _photoConsentTooltip =
|
||||
/// Cadre photo (centre transparent), même dossier que `photo.png`.
|
||||
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
|
||||
|
||||
bool _hasChildPhoto(ChildData c) {
|
||||
final b = c.imageBytes;
|
||||
if (b != null && b.isNotEmpty) return true;
|
||||
return c.imageFile != null;
|
||||
}
|
||||
|
||||
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
||||
final bytes = c.imageBytes;
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(bytes, fit: fit);
|
||||
}
|
||||
final f = c.imageFile;
|
||||
if (f != null) {
|
||||
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
|
||||
}
|
||||
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
|
||||
}
|
||||
|
||||
/// Widget pour afficher et éditer une carte enfant
|
||||
/// Utilisé dans le workflow d'inscription des parents
|
||||
class ChildCardWidget extends StatefulWidget {
|
||||
@ -174,8 +191,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
// ... (reste du code existant pour mobile/editable)
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
|
||||
@ -212,7 +227,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
config: config,
|
||||
scaleFactor: scaleFactor,
|
||||
photoSide: photoSide,
|
||||
currentChildImage: currentChildImage,
|
||||
childData: widget.childData,
|
||||
initialPhotoShadow: initialPhotoShadow,
|
||||
hoverPhotoShadow: hoverPhotoShadow,
|
||||
),
|
||||
@ -311,11 +326,12 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
required DisplayConfig config,
|
||||
required double scaleFactor,
|
||||
required double photoSide,
|
||||
required File? currentChildImage,
|
||||
required ChildData childData,
|
||||
required Color initialPhotoShadow,
|
||||
required Color hoverPhotoShadow,
|
||||
}) {
|
||||
final canInteract = !config.isReadonly;
|
||||
final hasPhoto = _hasChildPhoto(childData);
|
||||
final outerRadius = BorderRadius.circular(10 * scaleFactor);
|
||||
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor).
|
||||
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
|
||||
@ -332,12 +348,11 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
// Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué).
|
||||
clipBehavior:
|
||||
currentChildImage != null ? Clip.none : Clip.antiAlias,
|
||||
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: photoSide,
|
||||
height: photoSide,
|
||||
child: currentChildImage == null
|
||||
child: !hasPhoto
|
||||
? ClipRRect(
|
||||
borderRadius: outerRadius,
|
||||
child: Center(
|
||||
@ -356,15 +371,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: photoClipRadius,
|
||||
child: kIsWeb
|
||||
? Image.network(
|
||||
currentChildImage.path,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Image.file(
|
||||
currentChildImage,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
@ -379,7 +386,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentChildImage != null && canInteract)
|
||||
if (hasPhoto && canInteract)
|
||||
Positioned(
|
||||
top: 8 * scaleFactor,
|
||||
right: 8 * scaleFactor,
|
||||
@ -418,8 +425,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path;
|
||||
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path;
|
||||
else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
|
||||
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
|
||||
final cardWidth = screenSize.width / 2.0;
|
||||
|
||||
return SizedBox(
|
||||
@ -484,10 +490,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: currentChildImage != null
|
||||
? (kIsWeb
|
||||
? Image.network(currentChildImage.path, fit: BoxFit.cover)
|
||||
: Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
child: _hasChildPhoto(widget.childData)
|
||||
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
@ -538,8 +542,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
|
||||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||||
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// Pas de height fixe
|
||||
@ -594,10 +596,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: currentChildImage != null
|
||||
? (kIsWeb
|
||||
? Image.network(currentChildImage.path, fit: BoxFit.cover)
|
||||
: Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
child: _hasChildPhoto(widget.childData)
|
||||
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
|
||||
@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget {
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
final GlobalKey<FormFieldState<String>>? formFieldKey;
|
||||
|
||||
const CustomAppTextField({
|
||||
super.key,
|
||||
@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget {
|
||||
this.textInputAction,
|
||||
this.onFieldSubmitted,
|
||||
this.inputFormatters,
|
||||
this.autocorrect = true,
|
||||
this.enableSuggestions = true,
|
||||
this.formFieldKey,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double fontHeightMultiplier = 1.2;
|
||||
const double internalVerticalPadding = 16.0;
|
||||
final double dynamicFieldHeight = widget.fieldHeight;
|
||||
// Indication « non éditable » : libellé + hint en gris.
|
||||
final Color labelColor =
|
||||
widget.enabled ? Colors.black87 : Colors.grey;
|
||||
final Color hintColor = widget.enabled
|
||||
? Colors.black54.withValues(alpha: 0.7)
|
||||
: Colors.grey;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
widget.labelText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: widget.labelFontSize,
|
||||
color: Colors.black87,
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
// Pas de hauteur fixe sur le TextFormField : le message d’erreur du
|
||||
// validateur s’affiche en dessous ; un SizedBox fixe le masquait.
|
||||
SizedBox(
|
||||
width: widget.fieldWidth,
|
||||
height: dynamicFieldHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
@ -112,48 +124,63 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
obscureText: widget.obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
autofillHints: widget.autofillHints,
|
||||
textInputAction: widget.textInputAction,
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
onTap: widget.onTap,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: widget.inputFontSize,
|
||||
color: widget.enabled ? Colors.black87 : Colors.grey),
|
||||
validator: widget.validator ??
|
||||
(value) {
|
||||
if (!widget.enabled || widget.readOnly) return null;
|
||||
if (widget.isRequired &&
|
||||
(value == null || value.isEmpty)) {
|
||||
return 'Ce champ est obligatoire';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: GoogleFonts.merienda(
|
||||
fontSize: widget.inputFontSize,
|
||||
color: Colors.black54.withOpacity(0.7)),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
suffixIcon: widget.suffixIcon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 0.0),
|
||||
child: Icon(widget.suffixIcon,
|
||||
color: Colors.black54,
|
||||
size: widget.inputFontSize * 1.1),
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight:
|
||||
(dynamicFieldHeight - 16.0).clamp(24.0, double.infinity),
|
||||
),
|
||||
child: TextFormField(
|
||||
key: widget.formFieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
obscureText: widget.obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
enableSuggestions: widget.enableSuggestions,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
autofillHints: widget.autofillHints,
|
||||
textInputAction: widget.textInputAction,
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
onTap: widget.onTap,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: widget.inputFontSize,
|
||||
color: Colors.black87),
|
||||
validator: widget.validator ??
|
||||
(value) {
|
||||
if (!widget.enabled || widget.readOnly) return null;
|
||||
if (widget.isRequired &&
|
||||
(value == null || value.isEmpty)) {
|
||||
return 'Ce champ est obligatoire';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: GoogleFonts.merienda(
|
||||
fontSize: widget.inputFontSize,
|
||||
color: hintColor),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isDense: true,
|
||||
errorStyle: GoogleFonts.merienda(
|
||||
fontSize: widget.inputFontSize * 0.75,
|
||||
color: Colors.red.shade800,
|
||||
height: 1.2,
|
||||
),
|
||||
errorMaxLines: 3,
|
||||
suffixIcon: widget.suffixIcon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 0.0),
|
||||
child: Icon(widget.suffixIcon,
|
||||
color: Colors.black54,
|
||||
size: widget.inputFontSize * 1.1),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
219
frontend/lib/widgets/email_text_field.dart
Normal file
219
frontend/lib/widgets/email_text_field.dart
Normal file
@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/email_utils.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
|
||||
/// [TextFormField] e-mail : à la perte de focus → minuscules + validation immédiate.
|
||||
class EmailTextFormField extends StatefulWidget {
|
||||
const EmailTextFormField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.decoration,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.allowEmpty = false,
|
||||
this.readOnly = false,
|
||||
this.focusNode,
|
||||
this.autovalidateMode,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final InputDecoration? decoration;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final bool allowEmpty;
|
||||
final bool readOnly;
|
||||
final FocusNode? focusNode;
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
@override
|
||||
State<EmailTextFormField> createState() => _EmailTextFormFieldState();
|
||||
}
|
||||
|
||||
class _EmailTextFormFieldState extends State<EmailTextFormField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
late final bool _ownsFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsFocusNode = widget.focusNode == null;
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus || widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) {
|
||||
return;
|
||||
}
|
||||
final c = widget.controller;
|
||||
final normalized = normalizeEmailText(c.text);
|
||||
if (normalized != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
if (_ownsFocusNode) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deco = widget.decoration ??
|
||||
InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
border: const OutlineInputBorder(),
|
||||
);
|
||||
|
||||
return TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
readOnly: widget.readOnly,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
inputFormatters: const <TextInputFormatter>[EmailMaxLengthFormatter()],
|
||||
autovalidateMode: widget.autovalidateMode,
|
||||
decoration: deco,
|
||||
validator: widget.readOnly
|
||||
? null
|
||||
: (widget.validator ??
|
||||
(value) => validateEmail(value, allowEmpty: widget.allowEmpty)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Même logique que [EmailTextFormField] avec le style [CustomAppTextField].
|
||||
class EmailCustomTextField extends StatefulWidget {
|
||||
const EmailCustomTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
this.hintText = '',
|
||||
this.focusNode,
|
||||
this.fieldWidth = double.infinity,
|
||||
this.fieldHeight = 53.0,
|
||||
this.labelFontSize = 22.0,
|
||||
this.inputFontSize = 20.0,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.allowEmpty = false,
|
||||
this.style = CustomAppTextFieldStyle.beige,
|
||||
this.validator,
|
||||
this.textInputAction,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String labelText;
|
||||
final String hintText;
|
||||
final FocusNode? focusNode;
|
||||
final double fieldWidth;
|
||||
final double fieldHeight;
|
||||
final double labelFontSize;
|
||||
final double inputFontSize;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final bool allowEmpty;
|
||||
final CustomAppTextFieldStyle style;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
|
||||
@override
|
||||
State<EmailCustomTextField> createState() => _EmailCustomTextFieldState();
|
||||
}
|
||||
|
||||
class _EmailCustomTextFieldState extends State<EmailCustomTextField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
late final bool _ownsFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsFocusNode = widget.focusNode == null;
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus || widget.readOnly || !widget.enabled) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) {
|
||||
return;
|
||||
}
|
||||
final c = widget.controller;
|
||||
final normalized = normalizeEmailText(c.text);
|
||||
if (normalized != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
if (_ownsFocusNode) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomAppTextField(
|
||||
formFieldKey: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
style: widget.style,
|
||||
fieldWidth: widget.fieldWidth,
|
||||
fieldHeight: widget.fieldHeight,
|
||||
labelFontSize: widget.labelFontSize,
|
||||
inputFontSize: widget.inputFontSize,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: widget.textInputAction ?? TextInputAction.next,
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
validator: widget.validator ??
|
||||
((v) => validateEmail(v, allowEmpty: widget.allowEmpty)),
|
||||
isRequired: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
127
frontend/lib/widgets/french_phone_field.dart
Normal file
127
frontend/lib/widgets/french_phone_field.dart
Normal file
@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/phone_utils.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
|
||||
/// [TextFormField] téléphone France : formatage (0 automatique si 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@ -106,6 +108,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
FocusNode? _lastNameFocus;
|
||||
FocusNode? _firstNameFocus;
|
||||
FocusNode? _cityFocus;
|
||||
FocusNode? _emailFocus;
|
||||
final GlobalKey<FormFieldState<String>> _emailFormKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -113,7 +118,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
_lastNameController = TextEditingController(text: widget.initialData.lastName);
|
||||
_firstNameController = TextEditingController(text: widget.initialData.firstName);
|
||||
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
|
||||
_emailController = TextEditingController(text: widget.initialData.email);
|
||||
_emailController = TextEditingController(
|
||||
text: normalizeEmailText(widget.initialData.email),
|
||||
);
|
||||
_addressController = TextEditingController(text: widget.initialData.address);
|
||||
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
|
||||
_cityController = TextEditingController(text: widget.initialData.city);
|
||||
@ -132,9 +139,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
_lastNameFocus = FocusNode();
|
||||
_firstNameFocus = FocusNode();
|
||||
_cityFocus = FocusNode();
|
||||
_emailFocus = FocusNode();
|
||||
_lastNameFocus!.addListener(_onLastNameFocusChange);
|
||||
_firstNameFocus!.addListener(_onFirstNameFocusChange);
|
||||
_cityFocus!.addListener(_onCityFocusChange);
|
||||
_emailFocus!.addListener(_onEmailFocusChange);
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +168,92 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
_applyPersonNameFormat(_cityController);
|
||||
}
|
||||
|
||||
void _onEmailFocusChange() {
|
||||
if (_emailFocus == null || _emailFocus!.hasFocus) {
|
||||
return;
|
||||
}
|
||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||
return;
|
||||
}
|
||||
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
|
||||
return;
|
||||
}
|
||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||
return;
|
||||
}
|
||||
final normalized = normalizeEmailText(_emailController.text);
|
||||
if (normalized != _emailController.text) {
|
||||
_emailController.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
// Pas d’erreur « obligatoire » au blur si le champ est encore vide :
|
||||
// évite un message dès l’activation du parent 2 (focus / rebuild) et
|
||||
// la soumission du formulaire valide toujours.
|
||||
if (normalizeEmailText(_emailController.text).isEmpty) {
|
||||
return;
|
||||
}
|
||||
_emailFormKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
String? _validateRequiredFrenchPhone(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Ce champ est obligatoire';
|
||||
}
|
||||
return validateFrenchNationalPhone(value, allowEmpty: false);
|
||||
}
|
||||
|
||||
String? _validateRequiredEmail(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Ce champ est obligatoire.';
|
||||
}
|
||||
return validateEmail(value, allowEmpty: true);
|
||||
}
|
||||
|
||||
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
|
||||
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
|
||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||
return null;
|
||||
}
|
||||
return _validateRequiredFrenchPhone(value);
|
||||
}
|
||||
|
||||
String? _validateEmailIfSecondParentNeeded(String? value) {
|
||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||
return null;
|
||||
}
|
||||
return _validateRequiredEmail(value);
|
||||
}
|
||||
|
||||
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
|
||||
String? _validateAddressLineIfManualEntry(String? value) {
|
||||
if (!_fieldsEnabled) {
|
||||
return null;
|
||||
}
|
||||
if (widget.showSameAddressCheckbox && _sameAddress) {
|
||||
return null;
|
||||
}
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Ce champ est obligatoire';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
|
||||
String? _validatePostalCodeField(String? value) {
|
||||
if (!_fieldsEnabled) {
|
||||
return null;
|
||||
}
|
||||
if (widget.showSameAddressCheckbox && _sameAddress) {
|
||||
return null;
|
||||
}
|
||||
return validateFrenchPostalCode(value, allowEmpty: false);
|
||||
}
|
||||
|
||||
void _applyPersonNameFormat(TextEditingController controller) {
|
||||
final formatted = formatPersonNameCase(controller.text);
|
||||
if (formatted == controller.text) {
|
||||
@ -175,9 +270,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
_lastNameFocus?.removeListener(_onLastNameFocusChange);
|
||||
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
|
||||
_cityFocus?.removeListener(_onCityFocusChange);
|
||||
_emailFocus?.removeListener(_onEmailFocusChange);
|
||||
_lastNameFocus?.dispose();
|
||||
_firstNameFocus?.dispose();
|
||||
_cityFocus?.dispose();
|
||||
_emailFocus?.dispose();
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
@ -225,12 +322,21 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
_applyPersonNameFormat(_firstNameController);
|
||||
_applyPersonNameFormat(_cityController);
|
||||
}
|
||||
// Parent 2 désactivé : pas de contrôle sur les champs (désactivés à l’écran).
|
||||
if (widget.showSecondPersonToggle && !_hasSecondPerson) {
|
||||
widget.onSubmit(
|
||||
PersonalInfoData(),
|
||||
hasSecondPerson: false,
|
||||
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
|
||||
final data = PersonalInfoData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
phone: normalizePhone(_phoneController.text),
|
||||
email: _emailController.text,
|
||||
email: normalizeEmailText(_emailController.text),
|
||||
address: _addressController.text,
|
||||
postalCode: _postalCodeController.text,
|
||||
city: _cityController.text,
|
||||
@ -635,7 +741,20 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
setState(() {
|
||||
_hasSecondPerson = value;
|
||||
_fieldsEnabled = value;
|
||||
if (value && _sameAddress) {
|
||||
_updateAddressFields();
|
||||
}
|
||||
});
|
||||
if (!value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Si l’utilisateur a déjà recoché Parent 2, ne pas valider :
|
||||
// sinon des champs vides affichent une erreur tout de suite.
|
||||
if (!mounted || _hasSecondPerson) {
|
||||
return;
|
||||
}
|
||||
_formKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
},
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
@ -747,8 +866,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
inputFormatters: _phoneInputFormatters,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
focusOrder: _focusPhone,
|
||||
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
@ -761,6 +881,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusEmail,
|
||||
fieldValidator: _validateEmailIfSecondParentNeeded,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
focusNode: _emailFocus,
|
||||
formFieldKey: _emailFormKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -775,6 +899,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Numéro et nom de votre rue',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusAddress,
|
||||
fieldValidator: widget.showSameAddressCheckbox
|
||||
? _validateAddressLineIfManualEntry
|
||||
: null,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
|
||||
@ -787,10 +914,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
config: config,
|
||||
label: 'Code Postal',
|
||||
controller: _postalCodeController,
|
||||
hint: 'Code postal',
|
||||
hint: '5 chiffres',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusPostal,
|
||||
fieldValidator: _validatePostalCodeField,
|
||||
inputFormatters: kFrenchPostalCodeInputFormatters,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
@ -804,6 +933,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusCity,
|
||||
focusNode: _cityFocus,
|
||||
fieldValidator: widget.showSameAddressCheckbox
|
||||
? _validateAddressLineIfManualEntry
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -997,8 +1129,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
inputFormatters: _phoneInputFormatters,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
focusOrder: _focusPhone,
|
||||
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -1011,6 +1144,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusEmail,
|
||||
fieldValidator: _validateEmailIfSecondParentNeeded,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
focusNode: _emailFocus,
|
||||
formFieldKey: _emailFormKey,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -1022,6 +1159,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Numéro et nom de votre rue',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusAddress,
|
||||
fieldValidator: widget.showSameAddressCheckbox
|
||||
? _validateAddressLineIfManualEntry
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -1030,10 +1170,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
config: config,
|
||||
label: 'Code Postal',
|
||||
controller: _postalCodeController,
|
||||
hint: 'Code postal',
|
||||
hint: '5 chiffres',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusPostal,
|
||||
fieldValidator: _validatePostalCodeField,
|
||||
inputFormatters: kFrenchPostalCodeInputFormatters,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -1046,6 +1188,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
focusOrder: _focusCity,
|
||||
focusNode: _cityFocus,
|
||||
fieldValidator: widget.showSameAddressCheckbox
|
||||
? _validateAddressLineIfManualEntry
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -1062,6 +1207,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
double? focusOrder,
|
||||
FocusNode? focusNode,
|
||||
GlobalKey<FormFieldState<String>>? formFieldKey,
|
||||
String? Function(String?)? fieldValidator,
|
||||
}) {
|
||||
if (config.isReadonly) {
|
||||
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
|
||||
@ -1074,7 +1221,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
value: displayValue,
|
||||
);
|
||||
} else {
|
||||
final effectiveKeyboardType = keyboardType ?? TextInputType.text;
|
||||
final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
|
||||
Widget field = CustomAppTextField(
|
||||
formFieldKey: formFieldKey,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
labelText: label,
|
||||
@ -1084,9 +1234,15 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
keyboardType: effectiveKeyboardType,
|
||||
autocorrect: !isEmail,
|
||||
enableSuggestions: !isEmail,
|
||||
autofillHints: isEmail ? const [AutofillHints.email] : null,
|
||||
textInputAction: isEmail ? TextInputAction.next : null,
|
||||
enabled: enabled,
|
||||
inputFormatters: inputFormatters,
|
||||
validator: fieldValidator,
|
||||
isRequired: fieldValidator == null,
|
||||
);
|
||||
if (focusOrder != null) {
|
||||
field = FocusTraversalOrder(
|
||||
@ -1098,12 +1254,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
static final _phoneInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
];
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
switch (widget.cardColor) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user