[#101] [Frontend] Inscription parent — API, soumission et validation

Squash merge de develop vers master.

Livrables principaux (ticket #101 et mise au point associée) :
- Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent
- Payload DTO (parents, enfants, photos base64, CGU) et services Auth
- Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées
- Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/

Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.).

Closes #101

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-04-11 18:07:24 +02:00
parent cde676c4f9
commit fdd1e06e77
49 changed files with 3181 additions and 830 deletions

1
.gitignore vendored
View File

@ -12,6 +12,7 @@ dist/
.env.*.local
# IDE
.cursor/
.idea/
.vscode/
*.swp

View File

@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d
# Environnement
NODE_ENV=development
# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU.
# Local : laisser vide → ./uploads/photos (relatif au cwd du processus).
# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml).
# UPLOAD_PHOTOS_DIR=
#
# Reverse proxy : si Nginx devant lAPI, augmenter la taille du corps (ex. client_max_body_size 16m;).
# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+.
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true

View File

@ -1,26 +1,87 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
import * as path from 'path';
import * as express from 'express';
/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */
function setStaticImageCorsHeaders(res: express.Response): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
const staticImageServeOptions = {
index: false,
fallthrough: true,
setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res),
};
/** Préflight si le navigateur interroge OPTIONS sur les médias. */
function uploadsCorsPreflight(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
if (req.method === 'OPTIONS') {
setStaticImageCorsHeaders(res);
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.status(204).end();
return;
}
next();
}
/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */
function resolveUploadsFilesystemRoot(): string {
const raw = process.env.UPLOAD_PHOTOS_DIR?.trim();
const photosDir = raw
? path.isAbsolute(raw)
? raw
: path.resolve(process.cwd(), raw)
: path.join(process.cwd(), 'uploads', 'photos');
return path.dirname(photosDir);
}
async function bootstrap() {
const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
// Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué.
app.useBodyParser('json', { limit: '15mb' });
app.useBodyParser('urlencoded', { extended: true, limit: '15mb' });
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
// CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes.
app.enableCors({
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
origin: true, // Reflète lOrigin (dev / prod) — routes API + cookies JWT
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true,
});
const expressApp = app.getHttpAdapter().getInstance();
const uploadsRoot = resolveUploadsFilesystemRoot();
// Photos : chemins stockés en base type /uploads/photos/...
// En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs).
expressApp.use('/uploads', uploadsCorsPreflight);
expressApp.use('/api/v1/uploads', uploadsCorsPreflight);
app.useStaticAssets(uploadsRoot, {
prefix: '/uploads/',
...staticImageServeOptions,
});
expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions));
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,

View File

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

View File

@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { 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],

View File

@ -1,12 +1,13 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QueryFailedError, Repository } from 'typeorm';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
@ -22,20 +23,26 @@ import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { StatutDossierType } from 'src/entities/dossiers.entity';
import { LoginDto } from './dto/login.dto';
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
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 {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UserService,
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>,
@ -211,7 +218,16 @@ export class AuthService {
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
let resultat: {
parent1: Users;
parent2: Users | null;
enfants: Children[];
tokenCreationMdp: string;
tokenCoParent: string | null;
};
try {
resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const parent1 = manager.create(Users, {
@ -321,6 +337,25 @@ export class AuthService {
}
}
// Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119)
const presentationTrim = dto.presentation_dossier?.trim();
const dossierFamilleEnt = manager.create(DossierFamille, {
numero_dossier: numeroDossier,
presentation: presentationTrim || undefined,
statut: StatutDossierType.ENVOYE,
parent: entiteParent,
});
const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt);
for (const enfantEnregistre of enfantsEnregistres) {
await manager.save(
DossierFamilleEnfant,
manager.create(DossierFamilleEnfant, {
id_dossier_famille: dossierFamilleSaved.id,
id_enfant: enfantEnregistre.id,
}),
);
}
return {
parent1: parent1Enregistre,
parent2: parent2Enregistre,
@ -329,6 +364,38 @@ export class AuthService {
tokenCoParent,
};
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
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) {
this.logger.error(
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
@ -336,6 +403,7 @@ export class AuthService {
co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
};
}
@ -404,7 +472,9 @@ export class AuthService {
const dateConsentementPhoto =
dto.consentement_photo ? new Date() : undefined;
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
let resultat: { user: Users };
try {
resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, {
@ -443,6 +513,12 @@ export class AuthService {
return { user: userEnregistre };
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
}
throw err;
}
return {
message:
@ -464,7 +540,12 @@ export class AuthService {
const extension = correspondances[1];
const tamponImage = Buffer.from(correspondances[2], 'base64');
const dossierUpload = '/app/uploads/photos';
const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim();
const dossierUpload = rawDir
? path.isAbsolute(rawDir)
? rawDir
: path.resolve(process.cwd(), rawDir)
: path.join(process.cwd(), 'uploads', 'photos');
await fs.mkdir(dossierUpload, { recursive: true });
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
@ -575,4 +656,13 @@ export class AuthService {
token: user.token_reprise,
};
}
/** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */
private isPostgresUniqueViolation(err: unknown): boolean {
if (!(err instanceof QueryFailedError)) {
return false;
}
const code = (err.driverError as { code?: string } | undefined)?.code;
return code === '23505';
}
}

View File

@ -42,6 +42,18 @@ export class DossierFamilleEnfantDto {
due_date?: Date;
@ApiProperty({ enum: StatutEnfantType })
status: StatutEnfantType;
@ApiProperty({
required: false,
description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)',
})
photo_url?: string;
@ApiProperty({
required: false,
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */

View File

@ -17,6 +17,7 @@ import {
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
import { Children } from 'src/entities/children.entity';
@Injectable()
export class ParentsService {
@ -209,6 +210,20 @@ export class ParentsService {
}
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto {
return {
id: child.id,
first_name: child.first_name,
last_name: child.last_name,
genre: child.gender,
birth_date: child.birth_date,
due_date: child.due_date,
status: child.status,
photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo,
};
}
private normalizeParentIds(parentIds: unknown): string[] {
if (Array.isArray(parentIds)) return parentIds.map(String);
if (typeof parentIds === 'string') {
@ -257,15 +272,7 @@ export class ParentsService {
if (p.parentChildren) {
for (const pc of p.parentChildren) {
if (pc.child && !enfantsMap.has(pc.child.id)) {
enfantsMap.set(pc.child.id, {
id: pc.child.id,
first_name: pc.child.first_name,
last_name: pc.child.last_name,
genre: pc.child.gender,
birth_date: pc.child.birth_date,
due_date: pc.child.due_date,
status: pc.child.status,
});
enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child));
}
}
}
@ -275,6 +282,16 @@ export class ParentsService {
}
}
// Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif)
if (dossierFamille?.enfants?.length) {
for (const dfe of dossierFamille.enfants) {
const c = dfe.enfant;
if (c && !enfantsMap.has(c.id)) {
enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c));
}
}
}
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
user_id: p.user_id,
email: p.user.email,

View File

@ -8,7 +8,7 @@ DO $$ BEGIN
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
CREATE TYPE genre_type AS ENUM ('H', 'F');
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');

View File

@ -57,6 +57,10 @@ services:
NODE_ENV: ${NODE_ENV}
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
# Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads)
UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos}
volumes:
- backend_uploads:/app/uploads/photos
depends_on:
- database
labels:
@ -66,6 +70,12 @@ services:
- "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver"
- "traefik.http.routers.ptitspas-api.priority=20"
- "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000"
# Fichiers statiques photos (URL relatives /uploads/...) — priorité > front (10)
- "traefik.http.routers.ptitspas-uploads.rule=Host(\"app.ptits-pas.fr\") && PathPrefix(\"/uploads\")"
- "traefik.http.routers.ptitspas-uploads.entrypoints=websecure"
- "traefik.http.routers.ptitspas-uploads.tls.certresolver=leresolver"
- "traefik.http.routers.ptitspas-uploads.priority=18"
- "traefik.http.routers.ptitspas-uploads.service=ptitspas-api"
networks:
- ptitspas_network
- proxy_network
@ -94,6 +104,7 @@ services:
volumes:
postgres_data:
backend_uploads:
networks:
ptitspas_network:

View File

@ -1,7 +1,7 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.6
**Date** : 25 Février 2026
**Version** : 1.7
**Date** : 25 Mars 2026
**Auteur** : Équipe PtitsPas
**Estimation totale** : ~208h
@ -9,7 +9,7 @@
## 🔗 Liste des tickets Gitea
**Les numéros de section dans ce document = numéros dissues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026).
**Les numéros de section dans ce document = numéros dissues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API).
| Gitea # | Titre (dépôt) | Statut |
|--------|----------------|--------|
@ -88,10 +88,10 @@
| 103 | Numéro de dossier backend | Ouvert |
| 104 | Numéro de dossier frontend | Ouvert |
| 105 | Statut « refusé » | Ouvert |
| 106 | Liste familles en attente | Ouvert |
| 107 | Onglet « À valider » + listes | Ouvert |
| 106 | Liste familles en attente | ✅ Fermé |
| 107 | Onglet « À valider » + listes | ✅ Fermé |
| 108 | Validation dossier famille | Ouvert |
| 109 | Modale de validation | Ouvert |
| 109 | Modale de validation | ✅ Fermé |
| 110 | Refus sans suppression | Ouvert |
| 111 | Reprise après refus backend | Ouvert |
| 112 | Reprise après refus frontend | Ouvert |
@ -100,6 +100,7 @@
| 115 | Rattachement parent backend | Ouvert |
| 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | Ouvert |
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
@ -107,7 +108,12 @@ class DossierFamille {
numeroDossier: json['numero_dossier']?.toString(),
parents: parentsList,
enfants: enfantsList,
presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(),
presentation: (json['texte_motivation'] ??
json['presentation'] ??
json['presentation_dossier'] ??
json['texteMotivation'] ??
json['presentationDossier'])
?.toString(),
);
}
@ -151,7 +157,7 @@ class ParentDossier {
factory ParentDossier.fromJson(Map<String, dynamic> json) {
return ParentDossier(
id: json['id']?.toString() ?? '',
id: json['user_id']?.toString() ?? json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(),
@ -194,7 +200,27 @@ class EnfantDossier {
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
static String? _optionalPhotoUrl(dynamic v) {
if (v == null) return null;
if (v is String) {
final s = v.trim();
return s.isEmpty ? null : s;
}
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} '
'prénom=${json['first_name'] ?? json['prenom']} | '
'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → '
'résolu=${resolvedPhoto ?? ""}',
);
}
return EnfantDossier(
id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
@ -203,8 +229,9 @@ class EnfantDossier {
gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(),
dueDate: json['due_date']?.toString(),
photoUrl: json['photo_url']?.toString(),
consentPhoto: json['consent_photo'] == true,
photoUrl: resolvedPhoto,
consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true,
);
}
}

View File

@ -1,7 +1,6 @@
import 'dart:io'; // Pour File
import '../models/card_assets.dart'; // Import de l'enum CardColorVertical
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
// import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé
class ParentData {
@ -29,25 +28,61 @@ class ParentData {
}
class ChildData {
static const Object _unsetImage = Object();
static const Object _unsetImageBytes = Object();
String firstName;
String lastName;
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 multipleBirth;
bool isUnbornChild;
File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
Uint8List? imageBytes;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
ChildData({
this.firstName = '',
this.lastName = '',
this.dob = '',
this.genre = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
this.imageBytes,
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

View File

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

View File

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

View File

@ -13,29 +13,15 @@ class AmRegisterStep1Screen extends StatelessWidget {
Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
// 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,
lastName: registrationData.lastName,
phone: registrationData.phone,
email: registrationData.email,
address: registrationData.streetAddress,
postalCode: registrationData.postalCode,
city: registrationData.city,
);
}
final initialData = PersonalInfoData(
firstName: registrationData.firstName,
lastName: registrationData.lastName,
phone: registrationData.phone,
email: registrationData.email,
address: registrationData.streetAddress,
postalCode: registrationData.postalCode,
city: registrationData.city,
);
return PersonalInfoFormScreen(
stepText: 'Étape 1/4',

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart';
@ -17,24 +15,9 @@ class AmRegisterStep2Screen extends StatefulWidget {
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
String? _photoPathFramework;
File? _photoFile;
Future<void> _pickPhoto() async {
// 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");
// TODO: brancher ImagePicker ; ne pas préremplir de chemin factice
}
@override
@ -53,20 +36,6 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
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(
stepText: 'Étape 2/4',
title: 'Vos informations professionnelles',

View File

@ -13,22 +13,13 @@ class AmRegisterStep3Screen extends StatelessWidget {
Widget build(BuildContext context) {
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(
stepText: 'Étape 3/4',
title: 'Présentation et Conditions',
cardColor: CardColorHorizontal.peach,
textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...',
initialText: initialText,
initialCguAccepted: initialCgu,
initialText: data.presentationText,
initialCguAccepted: data.cguAccepted,
previousRoute: '/am-register-step2',
onSubmit: (text, cguAccepted) {
data.updatePresentationAndCgu(

View File

@ -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 'Ladresse e-mail nest 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(

View File

@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../widgets/personal_info_form_screen.dart';
import '../../models/card_assets.dart';
@ -15,31 +14,15 @@ class ParentRegisterStep1Screen extends StatelessWidget {
final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
final parent1 = registrationData.parent1;
// 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,
lastName: parent1.lastName,
phone: parent1.phone,
email: parent1.email,
address: parent1.address,
postalCode: parent1.postalCode,
city: parent1.city,
);
}
final initialData = PersonalInfoData(
firstName: parent1.firstName,
lastName: parent1.lastName,
phone: parent1.phone,
email: parent1.email,
address: parent1.address,
postalCode: parent1.postalCode,
city: parent1.city,
);
return PersonalInfoFormScreen(
stepText: 'Étape 1/5',

View File

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

View File

@ -3,15 +3,16 @@ 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';
import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../models/card_assets.dart';
import '../../config/display_config.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
class ParentRegisterStep3Screen extends StatefulWidget {
// final UserRegistrationData registrationData; // Supprimé
@ -75,6 +76,22 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
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() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
@ -92,8 +109,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
void _addChild(UserRegistrationData registrationData) { // Prend registrationData
setState(() {
bool isUnborn = DataGenerator.boolean();
// Trouver la première couleur non utilisée
CardColorVertical cardColor = _childCardColors.firstWhere(
(color) => !_usedColors.contains(color),
@ -102,11 +117,11 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final newChild = ChildData(
lastName: registrationData.parent1.lastName,
firstName: DataGenerator.firstName(),
dob: DataGenerator.dob(isUnborn: isUnborn),
isUnbornChild: isUnborn,
photoConsent: DataGenerator.boolean(),
multipleBirth: DataGenerator.boolean(),
firstName: '',
dob: '',
isUnbornChild: false,
photoConsent: false,
multipleBirth: false,
cardColor: cardColor,
);
registrationData.addChild(newChild);
@ -134,23 +149,27 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final ImagePicker picker = ImagePicker();
try {
final XFile? pickedFile = await picker.pickImage(
source: ImageSource.gallery, imageQuality: 70, maxWidth: 1024, maxHeight: 1024);
source: ImageSource.gallery,
imageQuality: 60,
maxWidth: 900,
maxHeight: 900,
);
if (pickedFile != null) {
if (childIndex < registrationData.children.length) {
final oldChild = registrationData.children[childIndex];
final updatedChild = ChildData(
firstName: oldChild.firstName,
lastName: oldChild.lastName,
dob: oldChild.dob,
photoConsent: oldChild.photoConsent,
multipleBirth: oldChild.multipleBirth,
isUnbornChild: oldChild.isUnbornChild,
imageFile: File(pickedFile.path),
cardColor: oldChild.cardColor,
);
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"); }
}
@ -188,15 +207,8 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
);
if (picked != null) {
final oldChild = registrationData.children[childIndex];
final updatedChild = ChildData(
firstName: oldChild.firstName,
lastName: oldChild.lastName,
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,
final updatedChild = oldChild.copyWith(
dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}",
);
registrationData.updateChild(childIndex, updatedChild);
}
@ -287,40 +299,42 @@ 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, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
))),
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
))),
onFirstNameChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(firstName: value));
}),
onLastNameChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(lastName: value));
}),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index];
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
));
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
},
onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
var g = oldChild.genre;
if (!newValue && g == 'Autre') {
g = '';
}
registrationData.updateChild(
index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
},
onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1,
@ -343,7 +357,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
const SizedBox(height: 30),
// Boutons navigation en bas du scroll
_buildMobileButtons(context, config, screenSize),
_buildMobileButtons(context, config, screenSize, registrationData),
const SizedBox(height: 20),
],
),
@ -393,41 +407,43 @@ 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, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
))),
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
))),
onFirstNameChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(firstName: value));
}),
onLastNameChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(lastName: value));
}),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index];
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
));
registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
},
onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
},
final oldChild = registrationData.children[index];
var g = oldChild.genre;
if (!newValue && g == 'Autre') {
g = '';
}
registrationData.updateChild(
index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
},
onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1,
),
@ -455,7 +471,12 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
}
/// Boutons navigation mobile
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
Widget _buildMobileButtons(
BuildContext context,
DisplayConfig config,
Size screenSize,
UserRegistrationData registrationData,
) {
return Row(
children: [
Expanded(
@ -483,7 +504,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: () {
context.go('/parent-register-step4');
_normalizeChildrenNamesAndGoToStep4(context, registrationData);
},
width: double.infinity,
height: 50,

View File

@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart';
import '../../widgets/presentation_form_screen.dart';
import '../../models/card_assets.dart';
import '../../utils/data_generator.dart';
class ParentRegisterStep4Screen extends StatelessWidget {
const ParentRegisterStep4Screen({super.key});
@ -14,22 +13,13 @@ class ParentRegisterStep4Screen extends StatelessWidget {
Widget build(BuildContext context) {
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(
stepText: 'Étape 4/5',
title: 'Motivation de votre demande',
cardColor: CardColorHorizontal.green,
textFieldHint: 'Écrivez ici pour motiver votre demande...',
initialText: initialText,
initialCguAccepted: initialCgu,
initialText: registrationData.motivationText,
initialCguAccepted: registrationData.cguAccepted,
previousRoute: '/parent-register-step3',
onSubmit: (text, cguAccepted) {
registrationData.updateMotivation(text);

View File

@ -13,6 +13,7 @@ import '../../widgets/custom_navigation_button.dart';
import '../../widgets/personal_info_form_screen.dart';
import '../../widgets/child_card_widget.dart';
import '../../widgets/presentation_form_screen.dart';
import '../../services/auth_service.dart';
class ParentRegisterStep5Screen extends StatefulWidget {
const ParentRegisterStep5Screen({super.key});
@ -22,6 +23,36 @@ class ParentRegisterStep5Screen extends StatefulWidget {
}
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
Widget build(BuildContext context) {
final registrationData = Provider.of<UserRegistrationData>(context);
@ -102,12 +133,11 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Soumettre',
text: _isSubmitting ? 'Envoi…' : 'Soumettre',
style: NavigationButtonStyle.green,
onPressed: () {
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
_showConfirmationModal(context);
},
onPressed: _isSubmitting
? () {}
: () => _submitRegistration(context, registrationData),
width: double.infinity,
height: 50,
fontSize: 16,
@ -118,18 +148,20 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
),
)
else
ImageButton(
bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F),
width: 350,
height: 50,
fontSize: 18,
onPressed: () {
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
_showConfirmationModal(context);
},
),
_isSubmitting
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
)
: ImageButton(
bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F),
width: 350,
height: 50,
fontSize: 18,
onPressed: () => _submitRegistration(context, registrationData),
),
],
),
),
@ -216,11 +248,12 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
childIndex: index,
mode: DisplayMode.readonly,
onPickImage: () {},
onClearImage: () {},
onDateSelect: () {},
onFirstNameChanged: (v) {},
onLastNameChanged: (v) {},
onGenreChanged: (v) {},
onTogglePhotoConsent: (v) {},
onToggleMultipleBirth: (v) {},
onToggleIsUnborn: (v) {},
onRemove: () {},
canBeRemoved: false,
@ -239,14 +272,14 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
cardColor: CardColorHorizontal.green, // Changé de pink à green
textFieldHint: '',
initialText: data.motivationText,
initialCguAccepted: true, // Toujours true ici car déjà passé
initialCguAccepted: data.cguAccepted,
previousRoute: '',
onSubmit: (t, c) {},
onEdit: () => context.go('/parent-register-step4'),
);
}
void _showConfirmationModal(BuildContext context) {
void _showSuccessModal(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: false,

View File

@ -1,6 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/config/env.dart';
class ApiConfig {
// static const String baseUrl = 'http://localhost:3000/api/v1/';
static const String baseUrl = 'https://app.ptits-pas.fr/api/v1';
/// Aligné sur [Env.apiBaseUrl] (`--dart-define=API_BASE_URL=...`) pour que les images `/uploads/...` visent le même hôte que lAPI.
static String get baseUrl {
final root = Env.apiBaseUrl.replaceAll(RegExp(r'/+$'), '');
return '$root/api/v1';
}
/// Origine (schéma + hôte + port) dérivée de [baseUrl], pour préfixer les chemins `/uploads/...`.
static String get apiOrigin {
final uri = Uri.parse(baseUrl);
if (uri.hasScheme && uri.host.isNotEmpty) {
return '${uri.scheme}://${uri.authority}';
}
return baseUrl.replaceAll(RegExp(r'/api/v1/?.*'), '');
}
/// URL absolue pour une image renvoyée par lAPI (chemin type `/uploads/...`).
/// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose souvent que `/api`.
static String absoluteMediaUrl(String? pathOrUrl) {
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")');
}
return '';
}
final u = pathOrUrl.trim();
if (u.startsWith('http://') || u.startsWith('https://')) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u');
}
return u;
}
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
final out = u.startsWith('/') ? '$base$u' : '$base/$u';
if (kDebugMode) {
debugPrint(
'[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"',
);
}
return out;
}
// Auth endpoints
static const String login = '/auth/login';

View File

@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.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/tokenService.dart';
import '../utils/nir_utils.dart';
@ -183,26 +185,137 @@ class AuthService {
return;
}
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
final decoded = _tryDecodeJsonMap(response.body);
final message = _extractErrorMessage(decoded, response.statusCode);
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 String encodedBody;
try {
encodedBody = jsonEncode(body);
} catch (_) {
throw Exception(
'Impossible de préparer lenvoi (données trop volumineuses ou invalides). '
'Réessayez avec une photo plus légère ou sans caractères inhabituels.',
);
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'),
headers: ApiConfig.headers,
body: encodedBody,
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
'un pare-feu ou un bloqueur, puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = _tryDecodeJsonMap(response.body);
var message = _extractErrorMessage(decoded, response.statusCode);
message = _humanizeParentRegistrationHttpError(message, response.statusCode);
throw Exception(message);
}
static Map<String, dynamic>? _tryDecodeJsonMap(String body) {
if (body.isEmpty) return null;
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
return null;
} catch (_) {
return null;
}
}
/// Aplatit `message` (string, liste, ou objet Nest / AllExceptionsFilter).
static String? _flattenApiMessageField(dynamic field) {
if (field == null) return null;
if (field is String) {
final t = field.trim();
return t.isEmpty ? null : t;
}
if (field is List) {
final parts = field
.map(_flattenApiMessageField)
.whereType<String>()
.where((s) => s.isNotEmpty)
.toList();
if (parts.isEmpty) return null;
return parts.join('. ');
}
if (field is Map) {
if (field['message'] != null) {
final inner = _flattenApiMessageField(field['message']);
if (inner != null) return inner;
}
final err = field['error'];
if (err is String && err.trim().isNotEmpty) return err.trim();
}
return null;
}
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
static String _extractErrorMessage(dynamic decoded, int statusCode) {
const fallback = 'Erreur lors de l\'inscription';
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
final msg = decoded['message'];
if (msg == null) {
final err = decoded['error'];
return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)';
if (decoded == null || decoded is! Map) {
return '$fallback ($statusCode)';
}
if (msg is String) return msg;
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
if (msg is Map && msg['message'] != null) return msg['message'].toString();
final map = decoded;
final fromMessage = _flattenApiMessageField(map['message']);
if (fromMessage != null) return fromMessage;
final err = map['error'];
if (err is String && err.trim().isNotEmpty) return err.trim();
return '$fallback ($statusCode)';
}
/// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à linscription.
static String _humanizeParentRegistrationHttpError(String msg, int statusCode) {
if (statusCode == 409) return msg;
final lower = msg.toLowerCase().trim();
final looksLikeGenericServerError = lower == 'internal server error' ||
lower == 'internal server error.' ||
lower.contains('internal server error');
if (statusCode >= 500 && looksLikeGenericServerError) {
return 'Impossible d\'enregistrer votre demande pour le moment. '
'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : '
'connectez-vous ou utilisez une autre adresse pour le parent principal '
'(et pour le co-parent si vous en indiquez un). '
'Si le problème continue, réessayez plus tard ou contactez le support.';
}
if (statusCode >= 500) {
if (lower.contains('duplicate') ||
lower.contains('unique constraint') ||
lower.contains('23505') ||
lower.contains('already exist')) {
return 'Cette adresse e-mail semble déjà enregistrée. '
'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.';
}
}
return msg;
}
/// Rafraîchit le profil utilisateur depuis l'API
static Future<AppUser?> refreshCurrentUser() async {
final token = await TokenService.getToken();

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.dart';
@ -92,8 +93,12 @@ class UserService {
/// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
static Future<DossierUnifie> getDossier(String numeroDossier) async {
final encoded = Uri.encodeComponent(numeroDossier);
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
if (kDebugMode) {
debugPrint('[PetitsPas/dossier] GET $uri');
}
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
uri,
headers: await _headers(),
);
if (response.statusCode == 404) {
@ -113,7 +118,21 @@ class UserService {
if (decoded is! Map<String, dynamic>) {
throw FormatException('Réponse invalide');
}
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] réponse OK type=${dossier.type} | '
'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}',
);
if (dossier.isFamily) {
final f = dossier.asFamily;
debugPrint(
'[PetitsPas/dossier] famille ${f.numeroDossier} | '
'${f.enfants.length} enfant(s)',
);
}
}
return dossier;
} catch (e) {
if (e is FormatException) rethrow;
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');

View File

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

View File

@ -0,0 +1,57 @@
import 'package:flutter/services.dart';
/// Longueur maximale dune adresse e-mail (RFC 5321).
const int kEmailMaxLength = 254;
/// Trim + minuscules (usage à la perte de focus et avant validation côté API).
String normalizeEmailText(String raw) {
return raw.trim().toLowerCase();
}
/// Motif volontairement simple pour lUI (pas une validation RFC complète).
/// Local + @ + domaine avec au moins un point ; pas despaces.
final RegExp kAppEmailPattern = RegExp(
r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$',
);
/// Indique si [trimmed] est un e-mail plausible (insensible à la casse).
bool isValidEmailFormat(String trimmed) {
final s = normalizeEmailText(trimmed);
if (s.isEmpty || s.length > kEmailMaxLength) {
return false;
}
return kAppEmailPattern.hasMatch(s);
}
/// Validation centralisée pour les champs e-mail.
///
/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée.
String? validateEmail(String? raw, {bool allowEmpty = false}) {
final s = normalizeEmailText(raw ?? '');
if (s.isEmpty) {
return allowEmpty ? null : 'Ladresse e-mail est obligatoire.';
}
if (s.length > kEmailMaxLength) {
return 'Ladresse e-mail est trop longue ($kEmailMaxLength caractères maximum).';
}
if (!kAppEmailPattern.hasMatch(s)) {
return 'Le format de ladresse e-mail est incorrect.';
}
return null;
}
/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]).
class EmailMaxLengthFormatter extends TextInputFormatter {
const EmailMaxLengthFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.length <= kEmailMaxLength) {
return newValue;
}
return oldValue;
}
}

View File

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

View File

@ -0,0 +1,284 @@
import 'dart:convert';
import 'dart:typed_data';
import '../models/user_registration_data.dart';
import 'email_utils.dart';
/// Construction du body `POST /auth/register/parent` à partir du state d'inscription.
/// Aligné sur [RegisterParentCompletDto] (backend).
class ParentRegistrationPayload {
ParentRegistrationPayload._();
static final RegExp _phoneFr = RegExp(r'^(\+33|0)[1-9](\d{2}){4}$');
/// Aligné sur `GenreType` (backend) : JSON exact `H`, `F`, `Autre`.
static const Set<String> apiGenres = {'H', 'F', 'Autre'};
/// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent.
static String? validateForApi(UserRegistrationData d) {
final p1 = d.parent1;
final p1Email = normalizeEmailText(p1.email);
if (p1Email.isEmpty) {
return 'Lemail du parent principal est requis.';
}
if (!isValidEmailFormat(p1Email)) {
return 'Lemail du parent principal nest pas au bon format.';
}
if (p1.firstName.trim().length < 2) {
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
}
if (p1.lastName.trim().length < 2) {
return 'Le nom du parent principal doit contenir au moins 2 caractères.';
}
final tel = _normalizePhone(p1.phone);
if (!_phoneFr.hasMatch(tel)) {
return 'Le numéro de téléphone du parent principal nest pas valide (ex. 0612345678).';
}
if (!d.cguAccepted) {
return 'Vous devez accepter les CGU et la politique de confidentialité.';
}
if (d.children.isEmpty) {
return 'Au moins un enfant est requis.';
}
final p2 = d.parent2;
if (p2 != null) {
final any = p2.email.trim().isNotEmpty ||
p2.firstName.trim().isNotEmpty ||
p2.lastName.trim().isNotEmpty ||
p2.phone.trim().isNotEmpty;
if (any) {
final p2Email = normalizeEmailText(p2.email);
if (p2Email.isEmpty ||
p2.firstName.trim().length < 2 ||
p2.lastName.trim().length < 2) {
return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).';
}
if (!isValidEmailFormat(p2Email)) {
return 'Lemail du co-parent nest pas au bon format.';
}
if (p2Email == p1Email) {
return 'Lemail du co-parent doit être différent de celui du parent principal.';
}
final tel2 = _normalizePhone(p2.phone);
if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) {
return 'Le numéro de téléphone du co-parent nest pas valide.';
}
}
}
for (var i = 0; i < d.children.length; i++) {
final c = d.children[i];
final label = 'Enfant ${i + 1}';
if (!c.photoConsent) {
return '$label : le consentement photo est obligatoire.';
}
if (c.isUnbornChild) {
final due = _ddMmYyyyToIso(c.dob);
if (due == null) {
return '$label : indiquez une date de naissance prévisionnelle.';
}
if (!apiGenres.contains(c.genre)) {
return '$label : indiquez Fille, Garçon ou Inconnu.';
}
} else {
if (c.firstName.trim().length < 2) {
return '$label : le prénom doit contenir au moins 2 caractères.';
}
final birth = _ddMmYyyyToIso(c.dob);
if (birth == null) {
return '$label : indiquez une date de naissance valide.';
}
if (c.genre != 'H' && c.genre != 'F') {
return '$label : indiquez Fille ou Garçon.';
}
}
}
return null;
}
static Map<String, dynamic> toJson(UserRegistrationData d) {
final p1 = d.parent1;
final tel = _normalizePhone(p1.phone);
final body = <String, dynamic>{
'email': normalizeEmailText(p1.email),
'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;
}
/// Sous-type `image/` pour data-URL et extension côté API (`jpeg`, `png`, `gif`, `heic`, ).
static String _imageMimeFromMagicBytes(Uint8List bytes) {
if (bytes.length >= 8) {
if (bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'png';
}
if (bytes[0] == 0xFF && bytes[1] == 0xD8) {
return 'jpeg';
}
if (bytes.length >= 12 &&
bytes[0] == 0x52 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x46) {
return 'webp';
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x38 &&
(bytes[4] == 0x37 || bytes[4] == 0x39) &&
bytes[5] == 0x61) {
return 'gif';
}
if (bytes.length >= 12 &&
bytes[4] == 0x66 &&
bytes[5] == 0x74 &&
bytes[6] == 0x79 &&
bytes[7] == 0x70) {
final a = bytes[8], b = bytes[9], c = bytes[10], d = bytes[11];
if ((a == 0x68 && b == 0x65 && c == 0x69 && d == 0x63) ||
(a == 0x68 && b == 0x65 && c == 0x69 && d == 0x78) ||
(a == 0x6d && b == 0x69 && c == 0x66 && d == 0x31) ||
(a == 0x6d && b == 0x73 && c == 0x66 && d == 0x31)) {
return 'heic';
}
}
}
return 'jpeg';
}
/// (`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);
final mime = _imageMimeFromMagicBytes(bytes);
final safeName = prenom.isNotEmpty
? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.$mime'
: 'enfant_${index + 1}.$mime';
return ('data:image/$mime;base64,$b64', safeName);
}
static void _putIfNonEmpty(Map<String, dynamic> m, String key, String value) {
if (value.isNotEmpty) {
m[key] = value;
}
}
static String _normalizePhone(String raw) {
var s = raw.replaceAll(RegExp(r'\s'), '');
if (s.startsWith('0033')) {
s = '+33${s.substring(4)}';
}
return s;
}
/// `jj/mm/aaaa` `aaaa-mm-jj`, ou `null` si invalide.
static String? _ddMmYyyyToIso(String ddMmYyyy) {
if (ddMmYyyy.isEmpty) return null;
final parts = ddMmYyyy.split('/');
if (parts.length != 3) return null;
final day = int.tryParse(parts[0]);
final month = int.tryParse(parts[1]);
final year = int.tryParse(parts[2]);
if (day == null || month == null || year == null) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
}

View File

@ -8,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 19 (format national).
///
/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.).
/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée.
String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) {
final trimmed = raw?.trim() ?? '';
if (trimmed.isEmpty) {
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
}
final digits = normalizePhone(trimmed);
if (digits.isEmpty) {
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
}
if (!frenchNationalPhoneStartsWithZero(digits)) {
return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).';
}
if (digits.length < 10) {
return 'Le numéro doit contenir 10 chiffres.';
}
if (digits.length > 10) {
return 'Le numéro ne peut pas dépasser 10 chiffres.';
}
if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) {
return 'Numéro de téléphone invalide.';
}
return null;
}
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
/// 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 lutilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
/// - Sil commence déjà par **0**, aucun préfixe nest ajouté.
class FrenchPhoneNumberFormatter extends TextInputFormatter {
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(),
];

View File

@ -0,0 +1,19 @@
import 'package:flutter/services.dart';
/// Saisie code postal français : uniquement des chiffres, au plus 5.
final List<TextInputFormatter> kFrenchPostalCodeInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
];
/// Valide un code postal français (exactement 5 chiffres).
String? validateFrenchPostalCode(String? raw, {bool allowEmpty = false}) {
final s = raw?.trim() ?? '';
if (s.isEmpty) {
return allowEmpty ? null : 'Ce champ est obligatoire';
}
if (s.length != 5 || int.tryParse(s) == null) {
return 'Le code postal doit comporter 5 chiffres.';
}
return null;
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package: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'),
),

View File

@ -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',

View File

@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI.
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
],
),
)
: Image.network(
photoUrl,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
Widget build(BuildContext context) {
@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl);
if (kDebugMode) {
debugPrint(
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
'photoUrl modèle=${e.photoUrl ?? ""} | url affichée=${photoUrl.isEmpty ? "" : photoUrl}',
);
}
final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
}
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
const photoRadius = 8.0;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(photoRadius),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: Image.network(
photoUrl,
fit: BoxFit.contain,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: width,
height: height,
errorBuilder: (_, __, ___) => ColoredBox(

View File

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

View File

@ -1,14 +1,41 @@
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';
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart';
import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
const String _photoConsentTooltip =
'Obligatoire : cochez cette case pour autoriser lutilisation de la photo de lenfant.\n'
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.';
/// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
bool _hasChildPhoto(ChildData c) {
final b = c.imageBytes;
if (b != null && b.isNotEmpty) return true;
return c.imageFile != null;
}
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
final bytes = c.imageBytes;
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(bytes, fit: fit);
}
final f = c.imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
}
/// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents
@ -16,11 +43,14 @@ class ChildCardWidget extends StatefulWidget {
final ChildData childData;
final int childIndex;
final VoidCallback onPickImage;
/// Retire la photo sélectionnée (placeholder à la place).
final VoidCallback onClearImage;
final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged;
/// `H`, `F` ou `Autre` (API). « Inconnu » à lUI = `Autre`.
final ValueChanged<String> onGenreChanged;
final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove;
final bool canBeRemoved;
@ -32,11 +62,12 @@ class ChildCardWidget extends StatefulWidget {
required this.childData,
required this.childIndex,
required this.onPickImage,
required this.onClearImage,
required this.onDateSelect,
required this.onFirstNameChanged,
required this.onLastNameChanged,
required this.onGenreChanged,
required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn,
required this.onRemove,
required this.canBeRemoved,
@ -49,10 +80,24 @@ class ChildCardWidget extends StatefulWidget {
}
class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG portrait ~1:2 ; même idée quavant #78 : 345×1,1 pour 200 px de côté).
static double _desktopEditableCardWidth({
required double photoSide,
required double maxWidth,
required double scaleFactor,
}) {
final fromPhoto = photoSide * (345.0 * 1.1 / 200.0);
final minForFields = 300.0 + 44.0 * scaleFactor;
return math.min(maxWidth, math.max(minForFields, fromPhoto));
}
late TextEditingController _firstNameController;
late TextEditingController _lastNameController;
late TextEditingController _dobController;
FocusNode? _firstNameFocus;
FocusNode? _lastNameFocus;
@override
void initState() {
super.initState();
@ -65,6 +110,38 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// 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
@ -85,6 +162,10 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
@override
void dispose() {
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.dispose();
_lastNameFocus?.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_dobController.dispose();
@ -110,16 +191,25 @@ 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);
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
final Color initialPhotoShadow =
Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow);
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(
width: config.isMobile ? double.infinity : screenSize.width * 0.6,
width: editableCardWidth,
// On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
// height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor),
@ -132,24 +222,20 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column(
mainAxisSize: MainAxisSize.min,
children: [
// ... (contenu existant)
HoverReliefWidget(
onPressed: config.isReadonly ? null : widget.onPickImage,
borderRadius: BorderRadius.circular(10),
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
child: SizedBox(
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),
),
),
),
_buildEditablePhotoArea(
context: context,
config: config,
scaleFactor: scaleFactor,
photoSide: photoSide,
childData: widget.childData,
initialPhotoShadow: initialPhotoShadow,
hoverPhotoShadow: hoverPhotoShadow,
),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: config.isMobile ? 13.0 : 16.0,
),
SizedBox(height: 10.0 * scaleFactor),
Row(
@ -173,13 +259,16 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
],
),
SizedBox(height: 8.0 * scaleFactor),
_buildGenreSegment(context, config, scaleFactor),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
config: config,
scaleFactor: scaleFactor,
label: 'Prénom',
controller: _firstNameController,
hint: 'Facultatif si à naître',
hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom',
isRequired: !widget.childData.isUnbornChild,
focusNode: _firstNameFocus,
),
SizedBox(height: 5.0 * scaleFactor),
_buildField(
@ -188,6 +277,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
label: 'Nom',
controller: _lastNameController,
hint: 'Nom de l\'enfant',
focusNode: _lastNameFocus,
),
SizedBox(height: 8.0 * scaleFactor),
_buildField(
@ -200,27 +290,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: config.isReadonly ? null : widget.onDateSelect,
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)
@ -230,7 +299,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: widget.onRemove,
customBorder: const CircleBorder(),
child: Image.asset(
'assets/images/red_cross2.png',
'assets/images/cross.png',
width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36,
fit: BoxFit.contain,
@ -252,6 +321,96 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
);
}
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor).
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canInteract ? widget.onPickImage : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
// Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
width: photoSide,
height: photoSide,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && canInteract)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onClearImage,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: config.isMobile ? 26 : 30,
height: config.isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
@ -266,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(
@ -310,32 +468,41 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PHOTO (1/3)
// PHOTO (1/3) + consentement sous la photo
Expanded(
flex: 1,
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
],
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _hasChildPhoto(widget.childData)
? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: currentChildImage != null
? (kIsWeb
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
const SizedBox(height: 12),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 16.0,
),
),
],
),
),
),
@ -356,35 +523,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_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,
),
],
),
],
),
),
@ -396,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
@ -452,14 +596,18 @@ 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),
),
),
),
const SizedBox(height: 10),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 14.0,
),
const SizedBox(height: 16),
// Champs
@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_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),
Row(
children: [
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {},
checkboxSize: 20.0,
fontSize: 14.0,
),
],
),
],
),
const SizedBox(height: 8),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
],
),
),
@ -525,6 +644,111 @@ 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"
Widget _buildReadonlyField(String label, String value) {
return Column(
@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
bool readOnly = false,
VoidCallback? onTap,
IconData? suffixIcon,
FocusNode? focusNode,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
} else {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
isRequired: isRequired,

View File

@ -0,0 +1,130 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
/// [Image.network] avec en-tête `Authorization` uniquement si lURL nest pas un fichier statique public.
///
/// Les chemins `/uploads/...` sont servis sans auth (voir back + Traefik) : ne pas envoyer de Bearer,
/// sinon en **cross-origin** (ex. admin Flutter web en local, API en prod) le navigateur peut bloquer
/// la requête (CORS) et afficher licône « image cassée ».
class AuthNetworkImage extends StatefulWidget {
const AuthNetworkImage({
super.key,
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.loadingBuilder,
this.errorBuilder,
});
final String url;
final double? width;
final double? height;
final BoxFit fit;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
static bool isPublicUploadUrl(String url) {
final u = url.toLowerCase();
return u.contains('/uploads/');
}
@override
State<AuthNetworkImage> createState() => _AuthNetworkImageState();
}
class _AuthNetworkImageState extends State<AuthNetworkImage> {
late final Future<Map<String, String>?> _headersFuture;
@override
void initState() {
super.initState();
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
_headersFuture =
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] préparation chargement url=${widget.url} | '
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
);
}
}
static Future<Map<String, String>?> _loadHeaders() async {
final t = await TokenService.getToken();
if (t == null || t.isEmpty) return null;
return {'Authorization': 'Bearer $t'};
}
ImageErrorWidgetBuilder _wrapErrorBuilder() {
return (BuildContext context, Object error, StackTrace? stackTrace) {
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error',
);
}
final inner = widget.errorBuilder;
if (inner != null) {
return inner(context, error, stackTrace);
}
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: Icon(Icons.broken_image_outlined, color: Colors.grey.shade500),
),
);
};
}
@override
Widget build(BuildContext context) {
final err = _wrapErrorBuilder();
if (AuthNetworkImage.isPublicUploadUrl(widget.url)) {
return Image.network(
widget.url,
width: widget.width,
height: widget.height,
fit: widget.fit,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
}
return FutureBuilder<Map<String, String>?>(
future: _headersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return SizedBox(
width: widget.width,
height: widget.height,
child: ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
);
}
final headers = snapshot.data;
if (kDebugMode && headers != null) {
debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)');
}
return Image.network(
widget.url,
width: widget.width,
height: widget.height,
fit: widget.fit,
headers: headers,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
},
);
}
}

View File

@ -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 derreur du
// validateur saffiche 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,
),
),
],

View File

@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/email_utils.dart';
import 'custom_app_text_field.dart';
/// [TextFormField] e-mail : à la perte de focus minuscules + validation immédiate.
class EmailTextFormField extends StatefulWidget {
const EmailTextFormField({
super.key,
required this.controller,
this.decoration,
this.label,
this.hint,
this.allowEmpty = false,
this.readOnly = false,
this.focusNode,
this.autovalidateMode,
this.validator,
});
final TextEditingController controller;
final InputDecoration? decoration;
final String? label;
final String? hint;
final bool allowEmpty;
final bool readOnly;
final FocusNode? focusNode;
final AutovalidateMode? autovalidateMode;
final String? Function(String?)? validator;
@override
State<EmailTextFormField> createState() => _EmailTextFormFieldState();
}
class _EmailTextFormFieldState extends State<EmailTextFormField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
late final bool _ownsFocusNode;
@override
void initState() {
super.initState();
_ownsFocusNode = widget.focusNode == null;
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus || widget.readOnly) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) {
return;
}
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
if (_ownsFocusNode) {
_focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final deco = widget.decoration ??
InputDecoration(
labelText: widget.label,
hintText: widget.hint,
border: const OutlineInputBorder(),
);
return TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
inputFormatters: const <TextInputFormatter>[EmailMaxLengthFormatter()],
autovalidateMode: widget.autovalidateMode,
decoration: deco,
validator: widget.readOnly
? null
: (widget.validator ??
(value) => validateEmail(value, allowEmpty: widget.allowEmpty)),
);
}
}
/// Même logique que [EmailTextFormField] avec le style [CustomAppTextField].
class EmailCustomTextField extends StatefulWidget {
const EmailCustomTextField({
super.key,
required this.controller,
required this.labelText,
this.hintText = '',
this.focusNode,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 22.0,
this.inputFontSize = 20.0,
this.enabled = true,
this.readOnly = false,
this.allowEmpty = false,
this.style = CustomAppTextFieldStyle.beige,
this.validator,
this.textInputAction,
this.onFieldSubmitted,
});
final TextEditingController controller;
final String labelText;
final String hintText;
final FocusNode? focusNode;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final bool allowEmpty;
final CustomAppTextFieldStyle style;
final String? Function(String?)? validator;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
@override
State<EmailCustomTextField> createState() => _EmailCustomTextFieldState();
}
class _EmailCustomTextFieldState extends State<EmailCustomTextField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
late final bool _ownsFocusNode;
@override
void initState() {
super.initState();
_ownsFocusNode = widget.focusNode == null;
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus || widget.readOnly || !widget.enabled) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) {
return;
}
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
if (_ownsFocusNode) {
_focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomAppTextField(
formFieldKey: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
labelText: widget.labelText,
hintText: widget.hintText,
style: widget.style,
fieldWidth: widget.fieldWidth,
fieldHeight: widget.fieldHeight,
labelFontSize: widget.labelFontSize,
inputFontSize: widget.inputFontSize,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
enabled: widget.enabled,
readOnly: widget.readOnly,
autofillHints: const [AutofillHints.email],
textInputAction: widget.textInputAction ?? TextInputAction.next,
onFieldSubmitted: widget.onFieldSubmitted,
inputFormatters: const [EmailMaxLengthFormatter()],
validator: widget.validator ??
((v) => validateEmail(v, allowEmpty: widget.allowEmpty)),
isRequired: false,
);
}
}

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../utils/phone_utils.dart';
import 'custom_app_text_field.dart';
/// [TextFormField] téléphone France : formatage (0 automatique si 17, etc.) + validation optionnelle.
///
/// Préférer ce widget ou [frenchPhoneInputFormatters] + [validateFrenchNationalPhone] pour tout nouveau formulaire.
class FrenchPhoneTextFormField extends StatelessWidget {
const FrenchPhoneTextFormField({
super.key,
required this.controller,
this.decoration,
this.label,
this.hint,
this.allowEmpty = false,
this.readOnly = false,
this.focusNode,
this.autovalidateMode,
this.validator,
});
final TextEditingController controller;
final InputDecoration? decoration;
final String? label;
final String? hint;
final bool allowEmpty;
final bool readOnly;
final FocusNode? focusNode;
final AutovalidateMode? autovalidateMode;
final String? Function(String?)? validator;
@override
Widget build(BuildContext context) {
final deco = decoration ??
InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
);
return TextFormField(
controller: controller,
focusNode: focusNode,
readOnly: readOnly,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
autovalidateMode: autovalidateMode,
decoration: deco,
validator: readOnly
? null
: (validator ??
(value) => validateFrenchNationalPhone(value, allowEmpty: allowEmpty)),
);
}
}
/// Même logique que [FrenchPhoneTextFormField], avec le rendu [CustomAppTextField] (inscription, cartes, etc.).
class FrenchPhoneCustomTextField extends StatelessWidget {
const FrenchPhoneCustomTextField({
super.key,
required this.controller,
required this.labelText,
this.hintText = '',
this.focusNode,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 22.0,
this.inputFontSize = 20.0,
this.enabled = true,
this.readOnly = false,
this.allowEmpty = false,
this.style = CustomAppTextFieldStyle.beige,
this.validator,
this.suffixIcon,
this.onTap,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
});
final TextEditingController controller;
final String labelText;
final String hintText;
final FocusNode? focusNode;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final bool allowEmpty;
final CustomAppTextFieldStyle style;
final String? Function(String?)? validator;
final IconData? suffixIcon;
final VoidCallback? onTap;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
@override
Widget build(BuildContext context) {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: labelText,
hintText: hintText,
style: style,
fieldWidth: fieldWidth,
fieldHeight: fieldHeight,
labelFontSize: labelFontSize,
inputFontSize: inputFontSize,
keyboardType: TextInputType.phone,
enabled: enabled,
readOnly: readOnly,
onTap: onTap,
suffixIcon: suffixIcon,
inputFormatters: frenchPhoneInputFormatters,
autofillHints: autofillHints,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
validator: validator ??
((v) => validateFrenchNationalPhone(v, allowEmpty: allowEmpty)),
isRequired: false,
);
}
}

View File

@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget {
final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol
final Color initialShadowColor; // 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({
required this.child,
@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget {
this.enableHoverEffect = true, // Par défaut, l'effet est activé
this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15)
this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3)
this.clipBehavior = Clip.antiAlias,
super.key,
});
@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: elevation,
shadowColor: shadowColor,
borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias,
clipBehavior: widget.clipBehavior,
child: widget.child,
),
);
@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: widget.initialElevation, // Utilise l'élévation initiale
shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable
borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias,
clipBehavior: widget.clipBehavior,
child: widget.child,
);
}

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
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;
@ -76,6 +79,19 @@ class PersonalInfoFormScreen extends StatefulWidget {
}
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>();
late TextEditingController _lastNameController;
late TextEditingController _firstNameController;
@ -89,13 +105,22 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
bool _sameAddress = false;
bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override
void initState() {
super.initState();
_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);
@ -109,10 +134,147 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields();
}
if (widget.mode == DisplayMode.editable) {
_lastNameFocus = FocusNode();
_firstNameFocus = FocusNode();
_cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
}
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onCityFocusChange() {
if (_cityFocus == null || _cityFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_cityController);
}
void _onEmailFocusChange() {
if (_emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
// Pas derreur « obligatoire » au blur si le champ est encore vide :
// évite un message dès lactivation du parent 2 (focus / rebuild) et
// la soumission du formulaire valide toujours.
if (normalizeEmailText(_emailController.text).isEmpty) {
return;
}
_emailFormKey.currentState?.validate();
});
}
String? _validateRequiredFrenchPhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String? _validateRequiredEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire.';
}
return validateEmail(value, allowEmpty: true);
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredFrenchPhone(value);
}
String? _validateEmailIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredEmail(value);
}
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
String? _validateAddressLineIfManualEntry(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return null;
}
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
String? _validatePostalCodeField(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
return validateFrenchPostalCode(value, allowEmpty: false);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
@override
void dispose() {
_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();
@ -131,13 +293,50 @@ 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() {
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()) {
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,
@ -169,75 +368,84 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.stepText,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
),
SizedBox(height: config.isMobile ? 6 : 10),
Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 18 : 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize),
// Boutons mobile sous la carte (dans le scroll)
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté)
),
child: Row(
children: [
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
const SizedBox(width: 16), // Écart entre les boutons
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
],
child: _wrapScrollBodyIfEditable(
config: config,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.stepText,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
),
const SizedBox(height: 10),
SizedBox(height: config.isMobile ? 6 : 10),
Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 18 : 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize),
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05,
),
child: Row(
children: [
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavPrevious,
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavNext,
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
),
],
),
),
const SizedBox(height: 10),
],
],
],
),
),
),
),
@ -522,17 +730,34 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis,
),
),
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
});
},
activeColor: Theme.of(context).primaryColor,
_orderedFocus(
enabled: config.isEditable,
order: _focusSecondPersonToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
if (value && _sameAddress) {
_updateAddressFields();
}
});
if (!value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Si lutilisateur a déjà recoché Parent 2, ne pas valider :
// sinon des champs vides affichent une erreur tout de suite.
if (!mounted || _hasSecondPerson) {
return;
}
_formKey.currentState?.validate();
});
}
},
activeColor: Theme.of(context).primaryColor,
),
),
),
],
@ -558,17 +783,21 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
overflow: TextOverflow.ellipsis,
),
),
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
_orderedFocus(
enabled: config.isEditable,
order: _focusSameAddressToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
),
),
),
],
@ -606,6 +835,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
),
),
const SizedBox(width: 20),
@ -616,6 +847,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
),
),
],
@ -633,7 +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),
@ -645,6 +880,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
),
],
@ -658,6 +898,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
SizedBox(height: verticalSpacing),
@ -670,9 +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),
@ -684,6 +931,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
),
],
@ -852,6 +1104,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
),
const SizedBox(height: 12),
@ -862,6 +1116,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
),
const SizedBox(height: 12),
@ -873,7 +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),
@ -885,6 +1143,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
const SizedBox(height: 12),
@ -895,6 +1158,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
const SizedBox(height: 12),
@ -903,9 +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),
@ -916,6 +1186,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
],
);
@ -930,6 +1205,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
TextInputType? keyboardType,
bool enabled = true,
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)
@ -942,9 +1221,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue,
);
} else {
// Mode éditable : style adapté mobile/desktop
return CustomAppTextField(
final effectiveKeyboardType = keyboardType ?? TextInputType.text;
final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige,
@ -952,10 +1234,23 @@ 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(
order: NumericFocusOrder(focusOrder),
child: field,
);
}
return field;
}
}

View File

@ -0,0 +1,156 @@
/**
* POST /api/v1/auth/register/parent jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed).
* Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
*
* Les PNG dans ressources/Photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
* via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
*
* Usage : node scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import https from 'https';
import http from 'http';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function shrinkToJpeg(inputPath, label) {
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`;
execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true });
return out;
}
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
return `data:${mime};base64,${buf.toString('base64')}`;
}
const presentationDossier =
"Nous sommes Amélie DURAND et Julien ROUSSEAU, parents de Chloé et Hugo. " +
"Nous sommes divorcés ; Amélie assure la garde principale et nous pratiquons la garde alternée un week-end sur deux. " +
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
"Merci pour l'étude de notre dossier.";
const chloeSrc = path.join(photosDir, 'G_Chloé.png');
const hugoSrc = path.join(photosDir, 'G_Hugo.png');
let chloeJpg;
let hugoJpg;
try {
console.error('Réduction des photos (sharp-cli)…');
chloeJpg = shrinkToJpeg(chloeSrc, 'chloe');
hugoJpg = shrinkToJpeg(hugoSrc, 'hugo');
const body = {
email: 'amelie.durand@ptits-pas.fr',
prenom: 'Amélie',
nom: 'DURAND',
telephone: '0667788990',
adresse: '23 Rue Victor Hugo',
code_postal: '95870',
ville: 'Bezons',
co_parent_email: 'julien.rousseau@ptits-pas.fr',
co_parent_prenom: 'Julien',
co_parent_nom: 'ROUSSEAU',
co_parent_telephone: '0656677889',
co_parent_meme_adresse: false,
co_parent_adresse: '14 Rue Pasteur',
co_parent_code_postal: '95870',
co_parent_ville: 'Bezons',
enfants: [
{
prenom: 'Chloé',
nom: 'ROUSSEAU',
date_naissance: '2022-04-20',
genre: 'F',
photo_base64: toDataUri(chloeJpg),
photo_filename: 'chloe_rousseau.jpg',
grossesse_multiple: false,
},
{
prenom: 'Hugo',
nom: 'ROUSSEAU',
date_naissance: '2024-03-10',
genre: 'H',
photo_base64: toDataUri(hugoJpg),
photo_filename: 'hugo_rousseau.jpg',
grossesse_multiple: false,
},
],
presentation_dossier: presentationDossier,
acceptation_cgu: true,
acceptation_privacy: true,
};
const json = JSON.stringify(body);
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`);
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(json, 'utf8'),
},
};
const lib = url.protocol === 'https:' ? https : http;
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', (c) => {
data += c;
});
res.on('end', () => {
console.log('HTTP', res.statusCode);
try {
const j = JSON.parse(data);
console.log(JSON.stringify(j, null, 2));
} catch {
console.log(data.slice(0, 4000));
}
});
});
req.on('error', (e) => {
console.error('Erreur réseau:', e.message);
process.exit(1);
});
req.setTimeout(120000, () => {
req.destroy();
console.error('Timeout 120s');
process.exit(1);
});
req.write(json);
req.end();
} finally {
try {
if (chloeJpg) fs.unlinkSync(chloeJpg);
} catch {
/* ignore */
}
try {
if (hugoJpg) fs.unlinkSync(hugoJpg);
} catch {
/* ignore */
}
}

View File

@ -0,0 +1,102 @@
/**
* POST /api/v1/auth/register/parent jeu de test officiel David LECOMTE (père isolé).
* Email : david.lecomte@ptits-pas.fr
*
* Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL]
*/
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
return `data:image/png;base64,${buf.toString('base64')}`;
}
const presentationDossier =
"Je suis David LECOMTE, père isolé de Maxime. J'ai la garde complète de mon fils. " +
"Je recherche une assistante maternelle bienveillante à Bezons. " +
"En cas d'urgence, la personne à contacter est sa grand-mère paternelle. " +
"Merci pour l'étude de notre dossier.";
const body = {
email: 'david.lecomte@ptits-pas.fr',
prenom: 'David',
nom: 'LECOMTE',
telephone: '0645566778',
adresse: '31 Rue Émile Zola',
code_postal: '95870',
ville: 'Bezons',
enfants: [
{
prenom: 'Maxime',
nom: 'LECOMTE',
date_naissance: '2023-04-15',
genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')),
photo_filename: 'maxime_lecomte.png',
grossesse_multiple: false,
},
],
presentation_dossier: presentationDossier,
acceptation_cgu: true,
acceptation_privacy: true,
};
const json = JSON.stringify(body);
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`);
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(json, 'utf8'),
},
};
const lib = url.protocol === 'https:' ? https : http;
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', (c) => {
data += c;
});
res.on('end', () => {
console.log('HTTP', res.statusCode);
try {
const j = JSON.parse(data);
console.log(JSON.stringify(j, null, 2));
} catch {
console.log(data.slice(0, 4000));
}
});
});
req.on('error', (e) => {
console.error('Erreur réseau:', e.message);
process.exit(1);
});
req.setTimeout(120000, () => {
req.destroy();
console.error('Timeout 120s');
process.exit(1);
});
req.write(json);
req.end();

View File

@ -0,0 +1,126 @@
/**
* POST /api/v1/auth/register/parent jeu de test officiel famille MARTIN (docs/test-data + seed).
* Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr
*
* Usage : node scripts/register-parent-martin-test.mjs [BASE_URL]
* Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
*/
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
return `data:image/png;base64,${buf.toString('base64')}`;
}
const presentationDossier =
"Nous sommes Claire et Thomas MARTIN, parents d'Emma, Noah et Léa, triplés nés le 15 février 2023. " +
"Nous recherchons une assistante maternelle bienveillante et structurée pour accueillir nos trois enfants, " +
"avec une capacité d'accueil adaptée à la garde de plusieurs nourrissons. " +
"Nous habitons à Bezons ; nous tenons à remercier le service pour l'étude de notre dossier.";
const body = {
email: 'claire.martin@ptits-pas.fr',
prenom: 'Claire',
nom: 'MARTIN',
telephone: '0689567890',
adresse: '5 Avenue du Général de Gaulle',
code_postal: '95870',
ville: 'Bezons',
co_parent_email: 'thomas.martin@ptits-pas.fr',
co_parent_prenom: 'Thomas',
co_parent_nom: 'MARTIN',
co_parent_telephone: '0678456789',
co_parent_meme_adresse: true,
enfants: [
{
prenom: 'Emma',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')),
photo_filename: 'emma_martin.png',
grossesse_multiple: true,
},
{
prenom: 'Noah',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')),
photo_filename: 'noah_martin.png',
grossesse_multiple: true,
},
{
prenom: 'Léa',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')),
photo_filename: 'lea_martin.png',
grossesse_multiple: true,
},
],
presentation_dossier: presentationDossier,
acceptation_cgu: true,
acceptation_privacy: true,
};
const json = JSON.stringify(body);
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`);
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(json, 'utf8'),
},
};
const lib = url.protocol === 'https:' ? https : http;
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', (c) => {
data += c;
});
res.on('end', () => {
console.log('HTTP', res.statusCode);
try {
const j = JSON.parse(data);
console.log(JSON.stringify(j, null, 2));
} catch {
console.log(data.slice(0, 4000));
}
});
});
req.on('error', (e) => {
console.error('Erreur réseau:', e.message);
process.exit(1);
});
req.setTimeout(120000, () => {
req.destroy();
console.error('Timeout 120s');
process.exit(1);
});
req.write(json);
req.end();