[#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 .env.*.local
# IDE # IDE
.cursor/
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp

View File

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

View File

@ -1,26 +1,87 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger'; import { DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; 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() { async function bootstrap() {
const app = await NestFactory.create(AppModule, const app = await NestFactory.create<NestExpressApplication>(AppModule, {
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) // Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué.
app.useGlobalInterceptors(new LogRequestInterceptor()); 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({ 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'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true, 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( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,

View File

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

View File

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

View File

@ -1,12 +1,13 @@
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; 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 { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.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 { LoginDto } from './dto/login.dto';
import { RepriseDossierDto } from './dto/reprise-dossier.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto';
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
import { AppConfigService } from 'src/modules/config/config.service'; import { AppConfigService } from 'src/modules/config/config.service';
import { validateNir } from 'src/common/utils/nir.util'; import { validateNir } from 'src/common/utils/nir.util';
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private readonly usersService: UserService, private readonly usersService: UserService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
private readonly mailService: MailService,
private readonly numeroDossierService: NumeroDossierService, private readonly numeroDossierService: NumeroDossierService,
@InjectRepository(Parents) @InjectRepository(Parents)
private readonly parentsRepo: Repository<Parents>, private readonly parentsRepo: Repository<Parents>,
@ -211,7 +218,16 @@ export class AuthService {
const dateExpiration = new Date(); const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); 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 { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const parent1 = manager.create(Users, { 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 { return {
parent1: parent1Enregistre, parent1: parent1Enregistre,
parent2: parent2Enregistre, parent2: parent2Enregistre,
@ -329,6 +364,38 @@ export class AuthService {
tokenCoParent, 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 { return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
@ -336,6 +403,7 @@ export class AuthService {
co_parent_id: resultat.parent2?.id, co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id), enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }
@ -404,7 +472,9 @@ export class AuthService {
const dateConsentementPhoto = const dateConsentementPhoto =
dto.consentement_photo ? new Date() : undefined; 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 { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, { const user = manager.create(Users, {
@ -443,6 +513,12 @@ export class AuthService {
return { user: userEnregistre }; 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 { return {
message: message:
@ -464,7 +540,12 @@ export class AuthService {
const extension = correspondances[1]; const extension = correspondances[1];
const tamponImage = Buffer.from(correspondances[2], 'base64'); const tamponImage = Buffer.from(correspondances[2], 'base64');
const dossierUpload = '/app/uploads/photos'; const 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 }); await fs.mkdir(dossierUpload, { recursive: true });
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
@ -575,4 +656,13 @@ export class AuthService {
token: user.token_reprise, 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; due_date?: Date;
@ApiProperty({ enum: StatutEnfantType }) @ApiProperty({ enum: StatutEnfantType })
status: 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 */ /** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */

View File

@ -17,6 +17,7 @@ import {
DossierFamilleParentDto, DossierFamilleParentDto,
DossierFamilleEnfantDto, DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto'; } from './dto/dossier-famille-complet.dto';
import { Children } from 'src/entities/children.entity';
@Injectable() @Injectable()
export class ParentsService { 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. */ /** 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[] { private normalizeParentIds(parentIds: unknown): string[] {
if (Array.isArray(parentIds)) return parentIds.map(String); if (Array.isArray(parentIds)) return parentIds.map(String);
if (typeof parentIds === 'string') { if (typeof parentIds === 'string') {
@ -257,15 +272,7 @@ export class ParentsService {
if (p.parentChildren) { if (p.parentChildren) {
for (const pc of p.parentChildren) { for (const pc of p.parentChildren) {
if (pc.child && !enfantsMap.has(pc.child.id)) { if (pc.child && !enfantsMap.has(pc.child.id)) {
enfantsMap.set(pc.child.id, { enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child));
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,
});
} }
} }
} }
@ -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) => ({ const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
user_id: p.user_id, user_id: p.user_id,
email: p.user.email, 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'); CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN 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; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN 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'); CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');

View File

@ -57,6 +57,10 @@ services:
NODE_ENV: ${NODE_ENV} NODE_ENV: ${NODE_ENV}
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} 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: depends_on:
- database - database
labels: labels:
@ -66,6 +70,12 @@ services:
- "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver" - "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver"
- "traefik.http.routers.ptitspas-api.priority=20" - "traefik.http.routers.ptitspas-api.priority=20"
- "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000" - "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: networks:
- ptitspas_network - ptitspas_network
- proxy_network - proxy_network
@ -94,6 +104,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
backend_uploads:
networks: networks:
ptitspas_network: ptitspas_network:

View File

@ -1,7 +1,7 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas # 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.6 **Version** : 1.7
**Date** : 25 Février 2026 **Date** : 25 Mars 2026
**Auteur** : Équipe PtitsPas **Auteur** : Équipe PtitsPas
**Estimation totale** : ~208h **Estimation totale** : ~208h
@ -9,7 +9,7 @@
## 🔗 Liste des tickets Gitea ## 🔗 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 | | Gitea # | Titre (dépôt) | Statut |
|--------|----------------|--------| |--------|----------------|--------|
@ -88,10 +88,10 @@
| 103 | Numéro de dossier backend | Ouvert | | 103 | Numéro de dossier backend | Ouvert |
| 104 | Numéro de dossier frontend | Ouvert | | 104 | Numéro de dossier frontend | Ouvert |
| 105 | Statut « refusé » | Ouvert | | 105 | Statut « refusé » | Ouvert |
| 106 | Liste familles en attente | Ouvert | | 106 | Liste familles en attente | ✅ Fermé |
| 107 | Onglet « À valider » + listes | Ouvert | | 107 | Onglet « À valider » + listes | ✅ Fermé |
| 108 | Validation dossier famille | Ouvert | | 108 | Validation dossier famille | Ouvert |
| 109 | Modale de validation | Ouvert | | 109 | Modale de validation | ✅ Fermé |
| 110 | Refus sans suppression | Ouvert | | 110 | Refus sans suppression | Ouvert |
| 111 | Reprise après refus backend | Ouvert | | 111 | Reprise après refus backend | Ouvert |
| 112 | Reprise après refus frontend | Ouvert | | 112 | Reprise après refus frontend | Ouvert |
@ -100,6 +100,7 @@
| 115 | Rattachement parent backend | Ouvert | | 115 | Rattachement parent backend | Ouvert |
| 116 | Rattachement parent frontend | Ouvert | | 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | 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* *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'; import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
@ -107,7 +108,12 @@ class DossierFamille {
numeroDossier: json['numero_dossier']?.toString(), numeroDossier: json['numero_dossier']?.toString(),
parents: parentsList, parents: parentsList,
enfants: enfantsList, 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) { factory ParentDossier.fromJson(Map<String, dynamic> json) {
return ParentDossier( return ParentDossier(
id: json['id']?.toString() ?? '', id: json['user_id']?.toString() ?? json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '', email: json['email']?.toString() ?? '',
prenom: json['prenom']?.toString(), prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(), nom: json['nom']?.toString(),
@ -194,7 +200,27 @@ class EnfantDossier {
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); 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) { 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( return EnfantDossier(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),
@ -203,8 +229,9 @@ class EnfantDossier {
gender: (json['gender'] ?? json['genre'])?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(), status: json['status']?.toString(),
dueDate: json['due_date']?.toString(), dueDate: json['due_date']?.toString(),
photoUrl: json['photo_url']?.toString(), photoUrl: resolvedPhoto,
consentPhoto: json['consent_photo'] == true, consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true,
); );
} }
} }

View File

@ -1,7 +1,6 @@
import 'dart:io'; // Pour File import 'dart:io'; // Pour File
import '../models/card_assets.dart'; // Import de l'enum CardColorVertical import '../models/card_assets.dart'; // Import de l'enum CardColorVertical
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
// import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé // import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé
class ParentData { class ParentData {
@ -29,25 +28,61 @@ class ParentData {
} }
class ChildData { class ChildData {
static const Object _unsetImage = Object();
static const Object _unsetImageBytes = Object();
String firstName; String firstName;
String lastName; String lastName;
String dob; // Date de naissance ou prévisionnelle String dob; // Date de naissance ou prévisionnelle
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre;
bool photoConsent; bool photoConsent;
bool multipleBirth; bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
Uint8List? imageBytes;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
ChildData({ ChildData({
this.firstName = '', this.firstName = '',
this.lastName = '', this.lastName = '',
this.dob = '', this.dob = '',
this.genre = '',
this.photoConsent = false, this.photoConsent = false,
this.multipleBirth = false, this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
this.imageBytes,
required this.cardColor, // Rendre requis dans le constructeur required this.cardColor, // Rendre requis dans le constructeur
}); });
ChildData copyWith({
String? firstName,
String? lastName,
String? dob,
String? genre,
bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild,
Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes,
CardColorVertical? cardColor,
}) {
return ChildData(
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
dob: dob ?? this.dob,
genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes:
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
cardColor: cardColor ?? this.cardColor,
);
}
} }
// Nouvelle classe pour les détails bancaires // Nouvelle classe pour les détails bancaires

View File

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

View File

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

View File

@ -13,29 +13,15 @@ class AmRegisterStep1Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false); final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) final initialData = PersonalInfoData(
PersonalInfoData initialData; firstName: registrationData.firstName,
if (registrationData.firstName.isEmpty) { lastName: registrationData.lastName,
initialData = PersonalInfoData( phone: registrationData.phone,
firstName: 'Marie', email: registrationData.email,
lastName: 'DUBOIS', address: registrationData.streetAddress,
phone: '0696345678', postalCode: registrationData.postalCode,
email: 'marie.dubois@ptits-pas.fr', city: registrationData.city,
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,
);
}
return PersonalInfoFormScreen( return PersonalInfoFormScreen(
stepText: 'Étape 1/4', stepText: 'Étape 1/4',

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/bug_report_service.dart'; import 'package:p_tits_pas/services/bug_report_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import '../../widgets/image_button.dart'; import '../../widgets/image_button.dart';
import '../../widgets/custom_app_text_field.dart'; import '../../widgets/custom_app_text_field.dart';
import '../../services/auth_service.dart'; import '../../services/auth_service.dart';
@ -21,6 +22,10 @@ class _LoginPageState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
@ -36,22 +41,49 @@ class _LoginPageState extends State<LoginScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_desktopRiverLogoDimensionsFuture = _getImageDimensions(); _desktopRiverLogoDimensionsFuture = _getImageDimensions();
_emailFocus = FocusNode();
_passwordFocus = FocusNode();
_emailFocus.addListener(_onEmailFocusChange);
}
void _onEmailFocusChange() {
if (_emailFocus.hasFocus) {
return;
}
// Reporter au frame suivant : une mise à jour synchrone du controller pendant
// un transfert de focus (Tab) peut casser la navigation au clavier.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus.hasFocus) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_emailFormKey.currentState?.validate();
});
} }
@override @override
void dispose() { void dispose() {
_emailFocus.removeListener(_onEmailFocusChange);
_emailFocus.dispose();
_passwordFocus.dispose();
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
super.dispose(); super.dispose();
} }
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
final v = value ?? ''; final v = value?.trim() ?? '';
if (v.isEmpty) { if (v.isEmpty) {
return 'Veuillez entrer votre email'; return 'Veuillez entrer votre adresse e-mail.';
} }
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) { if (!isValidEmailFormat(v)) {
return 'Veuillez entrer un email valide'; return 'Ladresse e-mail nest pas valide.';
} }
return null; return null;
} }
@ -205,53 +237,75 @@ class _LoginPageState extends State<LoginScreen> {
height: h * 0.5, // 50% de la hauteur de l'écran height: h * 0.5, // 50% de la hauteur de l'écran
child: Padding( child: Padding(
padding: EdgeInsets.all(w * 0.02), // 2% de padding padding: EdgeInsets.all(w * 0.02), // 2% de padding
child: AutofillGroup( child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Champs côte à côte // Champs côte à côte
Row( FocusTraversalGroup(
crossAxisAlignment: CrossAxisAlignment.start, policy: OrderedTraversalPolicy(),
children: [ child: Row(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: CustomAppTextField( children: [
controller: _emailController, Expanded(
labelText: 'Email', child: FocusTraversalOrder(
hintText: 'Votre adresse email', order: const NumericFocusOrder(1),
keyboardType: TextInputType.emailAddress, child: CustomAppTextField(
autofillHints: const [ formFieldKey: _emailFormKey,
AutofillHints.username, controller: _emailController,
AutofillHints.email, focusNode: _emailFocus,
], labelText: 'Email',
textInputAction: TextInputAction.next, hintText: 'Votre adresse email',
validator: _validateEmail, keyboardType:
style: CustomAppTextFieldStyle.lavande, TextInputType.emailAddress,
fieldHeight: 53, autocorrect: false,
fieldWidth: double.infinity, 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),
const SizedBox(width: 20), Expanded(
Expanded( child: FocusTraversalOrder(
child: CustomAppTextField( order: const NumericFocusOrder(2),
controller: _passwordController, child: CustomAppTextField(
labelText: 'Mot de passe', controller: _passwordController,
hintText: 'Votre mot de passe', focusNode: _passwordFocus,
obscureText: true, labelText: 'Mot de passe',
autofillHints: const [ hintText: 'Votre mot de passe',
AutofillHints.password, obscureText: true,
], autofillHints: const [
textInputAction: TextInputAction.done, AutofillHints.password,
onFieldSubmitted: ],
_handlePasswordSubmitted, textInputAction: TextInputAction.done,
validator: _validatePassword, onFieldSubmitted:
style: CustomAppTextFieldStyle.jaune, _handlePasswordSubmitted,
fieldHeight: 53, validator: _validatePassword,
fieldWidth: double.infinity, style:
CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
fieldWidth: double.infinity,
),
),
), ),
), ],
], ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@ -461,46 +515,68 @@ class _LoginPageState extends State<LoginScreen> {
horizontal: 24, vertical: 20), horizontal: 24, vertical: 20),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup( child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: FocusTraversalGroup(
mainAxisSize: MainAxisSize.min, policy: OrderedTraversalPolicy(),
children: [ child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
CustomAppTextField( children: [
controller: _emailController, const SizedBox(height: 16),
labelText: 'Email', FocusTraversalOrder(
showLabel: false, order: const NumericFocusOrder(1),
hintText: 'Votre adresse email', child: CustomAppTextField(
keyboardType: TextInputType.emailAddress, formFieldKey: _emailFormKey,
autofillHints: const [ controller: _emailController,
AutofillHints.username, focusNode: _emailFocus,
AutofillHints.email, labelText: 'Email',
], showLabel: false,
textInputAction: TextInputAction.next, hintText: 'Votre adresse email',
validator: _validateEmail, keyboardType:
style: CustomAppTextFieldStyle.lavande, TextInputType.emailAddress,
fieldHeight: 48, autocorrect: false,
fieldWidth: double.infinity, enableSuggestions: false,
), autofillHints: const [
const SizedBox(height: 12), AutofillHints.username,
CustomAppTextField( AutofillHints.email,
controller: _passwordController, ],
labelText: 'Mot de passe', inputFormatters: const [
showLabel: false, EmailMaxLengthFormatter(),
hintText: 'Votre mot de passe', ],
obscureText: true, textInputAction: TextInputAction.next,
autofillHints: const [ onFieldSubmitted: (_) =>
AutofillHints.password _passwordFocus.requestFocus(),
], validator: _validateEmail,
textInputAction: TextInputAction.done, style:
onFieldSubmitted: _handlePasswordSubmitted, CustomAppTextFieldStyle.lavande,
validator: _validatePassword, fieldHeight: 48,
style: CustomAppTextFieldStyle.jaune, fieldWidth: double.infinity,
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) ...[ if (_errorMessage != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@ -571,6 +647,7 @@ class _LoginPageState extends State<LoginScreen> {
), ),
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8), padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap( child: Wrap(

View File

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

View File

@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/personal_info_form_screen.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
@ -19,21 +18,17 @@ class ParentRegisterStep2Screen extends StatelessWidget {
bool hasParent2 = parent2 != null; bool hasParent2 = parent2 != null;
bool sameAddress = false; bool sameAddress = false;
// Générer des données de test si vide
PersonalInfoData initialData; PersonalInfoData initialData;
if (parent2 == null || parent2.firstName.isEmpty) { if (parent2 == null || parent2.firstName.isEmpty) {
final genFirstName = DataGenerator.firstName(); sameAddress = false;
final genLastName = DataGenerator.lastName();
sameAddress = DataGenerator.boolean();
initialData = PersonalInfoData( initialData = PersonalInfoData(
firstName: genFirstName, firstName: parent2?.firstName ?? '',
lastName: genLastName, lastName: parent2?.lastName ?? '',
phone: DataGenerator.phone(), phone: parent2?.phone ?? '',
email: DataGenerator.email(genFirstName, genLastName), email: parent2?.email ?? '',
address: sameAddress ? parent1.address : DataGenerator.address(), address: parent2?.address ?? '',
postalCode: sameAddress ? parent1.postalCode : DataGenerator.postalCode(), postalCode: parent2?.postalCode ?? '',
city: sameAddress ? parent1.city : DataGenerator.city(), city: parent2?.city ?? '',
); );
} else { } else {
sameAddress = (parent2.address == parent1.address && 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 'dart:math' as math; // Pour la rotation du chevron
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io' show File; import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../widgets/hover_relief_widget.dart'; import '../../widgets/hover_relief_widget.dart';
import '../../widgets/child_card_widget.dart'; import '../../widgets/child_card_widget.dart';
import '../../widgets/custom_navigation_button.dart'; import '../../widgets/custom_navigation_button.dart';
import '../../models/user_registration_data.dart'; import '../../models/user_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
import '../../config/display_config.dart'; import '../../config/display_config.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
class ParentRegisterStep3Screen extends StatefulWidget { class ParentRegisterStep3Screen extends StatefulWidget {
// final UserRegistrationData registrationData; // Supprimé // final UserRegistrationData registrationData; // Supprimé
@ -75,6 +76,22 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
super.dispose(); super.dispose();
} }
/// Même logique que nom / prénom parent : normalisation avant passage à létape suivante.
void _normalizeChildrenNamesAndGoToStep4(
BuildContext context,
UserRegistrationData registrationData,
) {
for (var i = 0; i < registrationData.children.length; i++) {
final c = registrationData.children[i];
final fn = formatPersonNameCase(c.firstName);
final ln = formatPersonNameCase(c.lastName);
if (fn != c.firstName || ln != c.lastName) {
registrationData.updateChild(i, c.copyWith(firstName: fn, lastName: ln));
}
}
context.go('/parent-register-step4');
}
void _scrollListener() { void _scrollListener() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final position = _scrollController.position; final position = _scrollController.position;
@ -92,8 +109,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
void _addChild(UserRegistrationData registrationData) { // Prend registrationData void _addChild(UserRegistrationData registrationData) { // Prend registrationData
setState(() { setState(() {
bool isUnborn = DataGenerator.boolean();
// Trouver la première couleur non utilisée // Trouver la première couleur non utilisée
CardColorVertical cardColor = _childCardColors.firstWhere( CardColorVertical cardColor = _childCardColors.firstWhere(
(color) => !_usedColors.contains(color), (color) => !_usedColors.contains(color),
@ -102,11 +117,11 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final newChild = ChildData( final newChild = ChildData(
lastName: registrationData.parent1.lastName, lastName: registrationData.parent1.lastName,
firstName: DataGenerator.firstName(), firstName: '',
dob: DataGenerator.dob(isUnborn: isUnborn), dob: '',
isUnbornChild: isUnborn, isUnbornChild: false,
photoConsent: DataGenerator.boolean(), photoConsent: false,
multipleBirth: DataGenerator.boolean(), multipleBirth: false,
cardColor: cardColor, cardColor: cardColor,
); );
registrationData.addChild(newChild); registrationData.addChild(newChild);
@ -134,23 +149,27 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
final ImagePicker picker = ImagePicker(); final ImagePicker picker = ImagePicker();
try { try {
final XFile? pickedFile = await picker.pickImage( 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 (pickedFile != null) {
if (childIndex < registrationData.children.length) { if (childIndex < registrationData.children.length) {
final oldChild = registrationData.children[childIndex]; final oldChild = registrationData.children[childIndex];
final updatedChild = ChildData( final bytes = await pickedFile.readAsBytes();
firstName: oldChild.firstName, if (bytes.isEmpty) return;
lastName: oldChild.lastName, File? file;
dob: oldChild.dob, if (!kIsWeb) {
photoConsent: oldChild.photoConsent, try {
multipleBirth: oldChild.multipleBirth, final f = File(pickedFile.path);
isUnbornChild: oldChild.isUnbornChild, if (await f.exists()) file = f;
imageFile: File(pickedFile.path), } catch (_) {}
cardColor: oldChild.cardColor, }
); final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file);
registrationData.updateChild(childIndex, updatedChild); registrationData.updateChild(childIndex, updatedChild);
} }
} }
} catch (e) { print("Erreur image: $e"); } } catch (e) { print("Erreur image: $e"); }
} }
@ -188,15 +207,8 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
); );
if (picked != null) { if (picked != null) {
final oldChild = registrationData.children[childIndex]; final oldChild = registrationData.children[childIndex];
final updatedChild = ChildData( final updatedChild = oldChild.copyWith(
firstName: oldChild.firstName, dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}",
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,
); );
registrationData.updateChild(childIndex, updatedChild); registrationData.updateChild(childIndex, updatedChild);
} }
@ -287,40 +299,42 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
// Générer les cartes enfants // Générer les cartes enfants
for (int index = 0; index < registrationData.children.length; index++) ...[ for (int index = 0; index < registrationData.children.length; index++) ...[
ChildCardWidget( ChildCardWidget(
key: ValueKey(registrationData.children[index].hashCode), key: ValueKey('parent_register_child_$index'),
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( onFirstNameChanged: (value) => setState(() {
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, final c = registrationData.children[index];
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor registrationData.updateChild(index, c.copyWith(firstName: value));
))), }),
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( onLastNameChanged: (value) => setState(() {
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, final c = registrationData.children[index];
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor registrationData.updateChild(index, c.copyWith(lastName: value));
))), }),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData( registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
},
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
}, },
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData( var g = oldChild.genre;
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), if (!newValue && g == 'Autre') {
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, g = '';
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor }
)); registrationData.updateChild(
index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
}, },
onRemove: () => _removeChild(index, registrationData), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
@ -343,7 +357,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
const SizedBox(height: 30), const SizedBox(height: 30),
// Boutons navigation en bas du scroll // Boutons navigation en bas du scroll
_buildMobileButtons(context, config, screenSize), _buildMobileButtons(context, config, screenSize, registrationData),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@ -393,41 +407,43 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 20.0), padding: const EdgeInsets.only(right: 20.0),
child: ChildCardWidget( child: ChildCardWidget(
key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données key: ValueKey('parent_register_child_$index'),
childData: registrationData.children[index], childData: registrationData.children[index],
childIndex: index, childIndex: index,
onPickImage: () => _pickImage(index, registrationData), onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData), onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( onFirstNameChanged: (value) => setState(() {
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, final c = registrationData.children[index];
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor registrationData.updateChild(index, c.copyWith(firstName: value));
))), }),
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( onLastNameChanged: (value) => setState(() {
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, final c = registrationData.children[index];
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor registrationData.updateChild(index, c.copyWith(lastName: value));
))), }),
onGenreChanged: (value) => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(genre: value));
}),
onTogglePhotoConsent: (newValue) { onTogglePhotoConsent: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData( registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue));
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
},
onToggleMultipleBirth: (newValue) {
final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData(
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
));
}, },
onToggleIsUnborn: (newValue) { onToggleIsUnborn: (newValue) {
final oldChild = registrationData.children[index]; final oldChild = registrationData.children[index];
registrationData.updateChild(index, ChildData( var g = oldChild.genre;
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), if (!newValue && g == 'Autre') {
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, g = '';
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor }
)); registrationData.updateChild(
}, index,
oldChild.copyWith(isUnbornChild: newValue, genre: g),
);
},
onRemove: () => _removeChild(index, registrationData), onRemove: () => _removeChild(index, registrationData),
canBeRemoved: registrationData.children.length > 1, canBeRemoved: registrationData.children.length > 1,
), ),
@ -455,7 +471,12 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
} }
/// Boutons navigation mobile /// Boutons navigation mobile
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildMobileButtons(
BuildContext context,
DisplayConfig config,
Size screenSize,
UserRegistrationData registrationData,
) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
@ -483,7 +504,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
text: 'Suivant', text: 'Suivant',
style: NavigationButtonStyle.green, style: NavigationButtonStyle.green,
onPressed: () { onPressed: () {
context.go('/parent-register-step4'); _normalizeChildrenNamesAndGoToStep4(context, registrationData);
}, },
width: double.infinity, width: double.infinity,
height: 50, height: 50,

View File

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

View File

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

View File

@ -1,6 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/config/env.dart';
class ApiConfig { class ApiConfig {
// static const String baseUrl = 'http://localhost:3000/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 const String baseUrl = 'https://app.ptits-pas.fr/api/v1'; 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 // Auth endpoints
static const String login = '/auth/login'; 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 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../models/am_registration_data.dart'; import '../models/am_registration_data.dart';
import '../models/user_registration_data.dart';
import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart'; import 'api/api_config.dart';
import 'api/tokenService.dart'; import 'api/tokenService.dart';
import '../utils/nir_utils.dart'; import '../utils/nir_utils.dart';
@ -183,26 +185,137 @@ class AuthService {
return; return;
} }
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null; final decoded = _tryDecodeJsonMap(response.body);
final message = _extractErrorMessage(decoded, response.statusCode); final message = _extractErrorMessage(decoded, response.statusCode);
throw Exception(message); throw Exception(message);
} }
/// Inscription parent complète (POST /auth/register/parent).
/// Succès : 201, pas de session rediriger vers le login.
static Future<void> registerParent(UserRegistrationData data) async {
final validationError = ParentRegistrationPayload.validateForApi(data);
if (validationError != null) {
throw Exception(validationError);
}
final body = ParentRegistrationPayload.toJson(data);
final 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). /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
static String _extractErrorMessage(dynamic decoded, int statusCode) { static String _extractErrorMessage(dynamic decoded, int statusCode) {
const fallback = 'Erreur lors de l\'inscription'; const fallback = 'Erreur lors de l\'inscription';
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; if (decoded == null || decoded is! Map) {
final msg = decoded['message']; return '$fallback ($statusCode)';
if (msg == null) {
final err = decoded['error'];
return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)';
} }
if (msg is String) return msg; final map = decoded;
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); final fromMessage = _flattenApiMessageField(map['message']);
if (msg is Map && msg['message'] != null) return msg['message'].toString(); if (fromMessage != null) return fromMessage;
final err = map['error'];
if (err is String && err.trim().isNotEmpty) return err.trim();
return '$fallback ($statusCode)'; 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 /// Rafraîchit le profil utilisateur depuis l'API
static Future<AppUser?> refreshCurrentUser() async { static Future<AppUser?> refreshCurrentUser() async {
final token = await TokenService.getToken(); final token = await TokenService.getToken();

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.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. /// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
static Future<DossierUnifie> getDossier(String numeroDossier) async { static Future<DossierUnifie> getDossier(String numeroDossier) async {
final encoded = Uri.encodeComponent(numeroDossier); 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( final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), uri,
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode == 404) { if (response.statusCode == 404) {
@ -113,7 +118,21 @@ class UserService {
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
throw FormatException('Réponse invalide'); 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) { } catch (e) {
if (e is FormatException) rethrow; if (e is FormatException) rethrow;
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}'); 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; return digits.length > 10 ? digits.substring(0, 10) : digits;
} }
/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`.
/// Chaîne vide : `true` (pas encore de saisie).
bool frenchNationalPhoneStartsWithZero(String digitsOnly) {
if (digitsOnly.isEmpty) {
return true;
}
return digitsOnly.startsWith('0');
}
/// Validation téléphone France : 10 chiffres, commence par `0`, 2 chiffre 19 (format national).
///
/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.).
/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée.
String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) {
final trimmed = raw?.trim() ?? '';
if (trimmed.isEmpty) {
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
}
final digits = normalizePhone(trimmed);
if (digits.isEmpty) {
return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.';
}
if (!frenchNationalPhoneStartsWithZero(digits)) {
return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).';
}
if (digits.length < 10) {
return 'Le numéro doit contenir 10 chiffres.';
}
if (digits.length > 10) {
return 'Le numéro ne peut pas dépasser 10 chiffres.';
}
if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) {
return 'Numéro de téléphone invalide.';
}
return null;
}
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78"). /// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "" etc.). /// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "" etc.).
String formatPhoneForDisplay(String raw) { String formatPhoneForDisplay(String raw) {
if (raw.trim().isEmpty) return raw; if (raw.trim().isEmpty) {
return raw;
}
final normalized = normalizePhone(raw); final normalized = normalizePhone(raw);
if (normalized.isEmpty) return raw; if (normalized.isEmpty) {
return raw;
}
return formatFrenchPhoneDigits(normalized);
}
/// Affiche les chiffres par paires (ex. `0612345678` `06 12 34 56 78`).
String formatFrenchPhoneDigits(String normalizedDigits) {
if (normalizedDigits.isEmpty) {
return '';
}
final buffer = StringBuffer(); final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) { for (var i = 0; i < normalizedDigits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' '); if (i > 0 && i.isEven) {
buffer.write(normalized[i]); buffer.write(' ');
}
buffer.write(normalizedDigits[i]);
} }
return buffer.toString(); return buffer.toString();
} }
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres. int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) {
final k = digitCountBeforeCursor.clamp(0, normalized.length);
if (k == 0) {
return 0;
}
return formatFrenchPhoneDigits(normalized.substring(0, k)).length;
}
/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres.
///
/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` `06`).
/// - Si lutilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`).
/// - Sil commence déjà par **0**, aucun préfixe nest ajouté.
class FrenchPhoneNumberFormatter extends TextInputFormatter { class FrenchPhoneNumberFormatter extends TextInputFormatter {
const FrenchPhoneNumberFormatter(); const FrenchPhoneNumberFormatter();
static const String _autoPrefixFirstDigits = '1234567';
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; var didPrependZero = false;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) { if (digits.isNotEmpty) {
if (i > 0 && i.isEven) buffer.write(' '); final first = digits[0];
buffer.write(normalized[i]); if (first == '8' || first == '9') {
} return oldValue;
final formatted = buffer.toString(); }
if (first != '0' && _autoPrefixFirstDigits.contains(first)) {
digits = '0$digits';
didPrependZero = true;
if (digits.length > 10) {
digits = digits.substring(0, 10);
}
} else if (first != '0') {
return oldValue;
}
}
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final formatted = formatFrenchPhoneDigits(normalized);
// Conserver la position du curseur : compter les chiffres avant la sélection
final sel = newValue.selection; final sel = newValue.selection;
final digitsBeforeCursor = newValue.text var digitsBeforeCursor = newValue.text
.substring(0, sel.start.clamp(0, newValue.text.length)) .substring(0, sel.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '') .replaceAll(RegExp(r'\D'), '')
.length; .length;
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); if (didPrependZero) {
final clampedOffset = newOffset.clamp(0, formatted.length); digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length);
}
final clampedOffset =
_cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length);
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter {
); );
} }
} }
/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.).
final List<TextInputFormatter> frenchPhoneInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
const FrenchPhoneNumberFormatter(),
];

View File

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

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart'; import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
@ -182,6 +183,9 @@ class _ParametresPanelState extends State<ParametresPanel> {
hintText: 'admin@example.com', hintText: 'admin@example.com',
), ),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
inputFormatters: const [EmailMaxLengthFormatter()],
), ),
actions: [ actions: [
TextButton( TextButton(
@ -191,7 +195,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final t = c.text.trim(); final t = c.text.trim();
if (t.isNotEmpty) Navigator.pop(ctx, t); if (t.isEmpty) {
return;
}
final err = validateEmail(t, allowEmpty: true);
if (err != null) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(err)),
);
return;
}
Navigator.pop(ctx, t);
}, },
child: const Text('Envoyer'), child: const Text('Envoyer'),
), ),

View File

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

View File

@ -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/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/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.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_modal_theme.dart';
import 'validation_refus_form.dart'; import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart'; import 'validation_valider_confirm_dialog.dart';
@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
static const double _proColumnMinWidth = 260; static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160; static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI. /// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
static String _fullPhotoUrl(String? url) { static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(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';
}
Widget _buildPhotoSection(AppUser u) { Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl); final photoUrl = _fullPhotoUrl(u.photoUrl);
@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
], ],
), ),
) )
: Image.network( : AuthNetworkImage(
photoUrl, url: photoUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
width: pw, width: pw,
height: ph, height: ph,

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.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/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/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.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_modal_theme.dart';
import 'validation_refus_form.dart'; import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart'; import 'validation_valider_confirm_dialog.dart';
@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
3: [2, 5] 3: [2, 5]
}; // Code postal étroit, Ville large }; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) { static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(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';
}
@override @override
Widget build(BuildContext context) { 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). /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) { Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl); 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); final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius), borderRadius: BorderRadius.circular(_enfantCardRadius),
@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
} }
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
const photoRadius = 8.0;
return Container( return Container(
width: width, width: width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(photoRadius),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)), border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
), ),
) )
: Image.network( : AuthNetworkImage(
photoUrl, url: photoUrl,
fit: BoxFit.contain, fit: BoxFit.cover,
width: width, width: width,
height: height, height: height,
errorBuilder: (_, __, ___) => ColoredBox( errorBuilder: (_, __, ___) => ColoredBox(

View File

@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget {
final double checkboxSize; final double checkboxSize;
final double checkmarkSizeFactor; final double checkmarkSizeFactor;
final double fontSize; final double fontSize;
/// Survol (desktop) ou appui long (mobile) pour afficher un texte daide.
final String? tooltip;
const AppCustomCheckbox({ const AppCustomCheckbox({
super.key, super.key,
@ -17,48 +19,67 @@ class AppCustomCheckbox extends StatelessWidget {
this.checkboxSize = 20.0, this.checkboxSize = 20.0,
this.checkmarkSizeFactor = 1.4, this.checkmarkSizeFactor = 1.4,
this.fontSize = 16.0, this.fontSize = 16.0,
this.tooltip,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return Row(
onTap: () => onChanged(!value), // Inverse la valeur au clic mainAxisSize: MainAxisSize.min,
behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable children: [
child: Row( GestureDetector(
mainAxisSize: MainAxisSize.min, onTap: () => onChanged(!value),
children: [ behavior: HitTestBehavior.opaque,
SizedBox( child: Row(
width: checkboxSize, mainAxisSize: MainAxisSize.min,
height: checkboxSize, children: [
child: Stack( SizedBox(
alignment: Alignment.center, width: checkboxSize,
clipBehavior: Clip.none, height: checkboxSize,
children: [ child: Stack(
Image.asset( alignment: Alignment.center,
'assets/images/square.png', clipBehavior: Clip.none,
height: checkboxSize, children: [
width: checkboxSize, 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( const SizedBox(width: 10),
'assets/images/coche.png', Flexible(
height: checkboxSize * checkmarkSizeFactor, child: Text(
width: checkboxSize * checkmarkSizeFactor, 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 if (tooltip != null && tooltip!.trim().isNotEmpty) ...[
Flexible( const SizedBox(width: 6),
child: Text( Tooltip(
label, message: tooltip!.trim(),
style: GoogleFonts.merienda(fontSize: fontSize), waitDuration: const Duration(milliseconds: 300),
overflow: TextOverflow.ellipsis, // Gérer le texte long 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:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../models/user_registration_data.dart'; import '../models/user_registration_data.dart';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart'; import 'hover_relief_widget.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
const String _photoConsentTooltip =
'Obligatoire : cochez cette case pour autoriser lutilisation de la photo de lenfant.\n'
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.';
/// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
bool _hasChildPhoto(ChildData c) {
final b = c.imageBytes;
if (b != null && b.isNotEmpty) return true;
return c.imageFile != null;
}
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
final bytes = c.imageBytes;
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(bytes, fit: fit);
}
final f = c.imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
}
/// Widget pour afficher et éditer une carte enfant /// Widget pour afficher et éditer une carte enfant
/// Utilisé dans le workflow d'inscription des parents /// Utilisé dans le workflow d'inscription des parents
@ -16,11 +43,14 @@ class ChildCardWidget extends StatefulWidget {
final ChildData childData; final ChildData childData;
final int childIndex; final int childIndex;
final VoidCallback onPickImage; final VoidCallback onPickImage;
/// Retire la photo sélectionnée (placeholder à la place).
final VoidCallback onClearImage;
final VoidCallback onDateSelect; final VoidCallback onDateSelect;
final ValueChanged<String> onFirstNameChanged; final ValueChanged<String> onFirstNameChanged;
final ValueChanged<String> onLastNameChanged; final ValueChanged<String> onLastNameChanged;
/// `H`, `F` ou `Autre` (API). « Inconnu » à lUI = `Autre`.
final ValueChanged<String> onGenreChanged;
final ValueChanged<bool> onTogglePhotoConsent; final ValueChanged<bool> onTogglePhotoConsent;
final ValueChanged<bool> onToggleMultipleBirth;
final ValueChanged<bool> onToggleIsUnborn; final ValueChanged<bool> onToggleIsUnborn;
final VoidCallback onRemove; final VoidCallback onRemove;
final bool canBeRemoved; final bool canBeRemoved;
@ -32,11 +62,12 @@ class ChildCardWidget extends StatefulWidget {
required this.childData, required this.childData,
required this.childIndex, required this.childIndex,
required this.onPickImage, required this.onPickImage,
required this.onClearImage,
required this.onDateSelect, required this.onDateSelect,
required this.onFirstNameChanged, required this.onFirstNameChanged,
required this.onLastNameChanged, required this.onLastNameChanged,
required this.onGenreChanged,
required this.onTogglePhotoConsent, required this.onTogglePhotoConsent,
required this.onToggleMultipleBirth,
required this.onToggleIsUnborn, required this.onToggleIsUnborn,
required this.onRemove, required this.onRemove,
required this.canBeRemoved, required this.canBeRemoved,
@ -49,10 +80,24 @@ class ChildCardWidget extends StatefulWidget {
} }
class _ChildCardWidgetState extends State<ChildCardWidget> { class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG portrait ~1:2 ; même idée quavant #78 : 345×1,1 pour 200 px de côté).
static double _desktopEditableCardWidth({
required double photoSide,
required double maxWidth,
required double scaleFactor,
}) {
final fromPhoto = photoSide * (345.0 * 1.1 / 200.0);
final minForFields = 300.0 + 44.0 * scaleFactor;
return math.min(maxWidth, math.max(minForFields, fromPhoto));
}
late TextEditingController _firstNameController; late TextEditingController _firstNameController;
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _dobController; late TextEditingController _dobController;
FocusNode? _firstNameFocus;
FocusNode? _lastNameFocus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -65,6 +110,38 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text)); _firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text)); _lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici // Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
if (widget.mode == DisplayMode.editable) {
_firstNameFocus = FocusNode();
_lastNameFocus = FocusNode();
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_lastNameFocus!.addListener(_onLastNameFocusChange);
}
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
} }
@override @override
@ -85,6 +162,10 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
@override @override
void dispose() { void dispose() {
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.dispose();
_lastNameFocus?.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_dobController.dispose(); _dobController.dispose();
@ -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 final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200 ? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90); final Color initialPhotoShadow =
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); 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( 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 // On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
// height: config.isMobile ? null : 600.0 * scaleFactor, // height: config.isMobile ? null : 600.0 * scaleFactor,
padding: EdgeInsets.all(22.0 * scaleFactor), padding: EdgeInsets.all(22.0 * scaleFactor),
@ -132,24 +222,20 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ... (contenu existant) _buildEditablePhotoArea(
HoverReliefWidget( context: context,
onPressed: config.isReadonly ? null : widget.onPickImage, config: config,
borderRadius: BorderRadius.circular(10), scaleFactor: scaleFactor,
initialShadowColor: initialPhotoShadow, photoSide: photoSide,
hoverShadowColor: hoverPhotoShadow, childData: widget.childData,
child: SizedBox( initialPhotoShadow: initialPhotoShadow,
height: 200.0 * (config.isMobile ? 0.8 : 1.0), hoverPhotoShadow: hoverPhotoShadow,
width: 200.0 * (config.isMobile ? 0.8 : 1.0), ),
child: Center( SizedBox(height: 8.0 * scaleFactor),
child: Padding( _buildPhotoConsentRow(
padding: EdgeInsets.all(5.0 * scaleFactor), context: context,
child: currentChildImage != null config: config,
? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover)) labelFontSize: config.isMobile ? 13.0 : 16.0,
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
),
),
),
), ),
SizedBox(height: 10.0 * scaleFactor), SizedBox(height: 10.0 * scaleFactor),
Row( Row(
@ -173,13 +259,16 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
], ],
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildGenreSegment(context, config, scaleFactor),
SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
config: config, config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
label: 'Prénom', label: 'Prénom',
controller: _firstNameController, controller: _firstNameController,
hint: 'Facultatif si à naître', hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom',
isRequired: !widget.childData.isUnbornChild, isRequired: !widget.childData.isUnbornChild,
focusNode: _firstNameFocus,
), ),
SizedBox(height: 5.0 * scaleFactor), SizedBox(height: 5.0 * scaleFactor),
_buildField( _buildField(
@ -188,6 +277,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
label: 'Nom', label: 'Nom',
controller: _lastNameController, controller: _lastNameController,
hint: 'Nom de l\'enfant', hint: 'Nom de l\'enfant',
focusNode: _lastNameFocus,
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildField( _buildField(
@ -200,27 +290,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: config.isReadonly ? null : widget.onDateSelect, onTap: config.isReadonly ? null : widget.onDateSelect,
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
), ),
SizedBox(height: 10.0 * scaleFactor),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
SizedBox(height: 5.0 * scaleFactor),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth,
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
fontSize: config.isMobile ? 13.0 : 16.0,
),
],
),
], ],
), ),
if (widget.canBeRemoved && !config.isReadonly) if (widget.canBeRemoved && !config.isReadonly)
@ -230,7 +299,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
onTap: widget.onRemove, onTap: widget.onRemove,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Image.asset( child: Image.asset(
'assets/images/red_cross2.png', 'assets/images/cross.png',
width: config.isMobile ? 30 : 36, width: config.isMobile ? 30 : 36,
height: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36,
fit: BoxFit.contain, fit: BoxFit.contain,
@ -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) /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
@ -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('peach')) horizontalCardAsset = CardColorHorizontal.peach.path;
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path; else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path;
else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path; else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
final File? currentChildImage = widget.childData.imageFile;
final cardWidth = screenSize.width / 2.0; final cardWidth = screenSize.width / 2.0;
return SizedBox( return SizedBox(
@ -310,32 +468,41 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// PHOTO (1/3) // PHOTO (1/3) + consentement sous la photo
Expanded( Expanded(
flex: 1, flex: 1,
child: Center( child: Center(
child: AspectRatio( child: Column(
aspectRatio: 1, mainAxisSize: MainAxisSize.min,
child: Container( children: [
decoration: BoxDecoration( AspectRatio(
borderRadius: BorderRadius.circular(18), aspectRatio: 1,
boxShadow: [ child: Container(
BoxShadow( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1), borderRadius: BorderRadius.circular(18),
blurRadius: 10, boxShadow: [
offset: const Offset(0, 5), 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( const SizedBox(height: 12),
borderRadius: BorderRadius.circular(18), _buildPhotoConsentRow(
child: currentChildImage != null context: context,
? (kIsWeb config: config,
? Image.network(currentChildImage.path, fit: BoxFit.cover) labelFontSize: 16.0,
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
), ),
), ],
), ),
), ),
), ),
@ -356,35 +523,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _dobController.text
), ),
const SizedBox(height: 12),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
], ],
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 18),
// Consentements
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppCustomCheckbox(
label: 'Consentement photo',
value: widget.childData.photoConsent,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
const SizedBox(width: 32),
AppCustomCheckbox(
label: 'Naissance multiple',
value: widget.childData.multipleBirth,
onChanged: (v) {}, // Readonly
checkboxSize: 22.0,
fontSize: 16.0,
),
],
),
], ],
), ),
), ),
@ -396,8 +542,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
/// Carte en mode readonly MOBILE avec hauteur adaptative /// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) { Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
final File? currentChildImage = widget.childData.imageFile;
return Container( return Container(
width: double.infinity, width: double.infinity,
// Pas de height fixe // Pas de height fixe
@ -452,14 +596,18 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
child: currentChildImage != null child: _hasChildPhoto(widget.childData)
? (kIsWeb ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover)
? Image.network(currentChildImage.path, fit: BoxFit.cover)
: Image.file(currentChildImage, fit: BoxFit.cover))
: Image.asset('assets/images/photo.png', fit: BoxFit.contain), : Image.asset('assets/images/photo.png', fit: BoxFit.contain),
), ),
), ),
), ),
const SizedBox(height: 10),
_buildPhotoConsentRow(
context: context,
config: config,
labelFontSize: 14.0,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Champs // Champs
@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
_dobController.text _dobController.text
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
_buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)),
// 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,
),
],
),
],
),
], ],
), ),
), ),
@ -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" /// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) { Widget _buildReadonlyField(String label, String value) {
return Column( return Column(
@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
bool readOnly = false, bool readOnly = false,
VoidCallback? onTap, VoidCallback? onTap,
IconData? suffixIcon, IconData? suffixIcon,
FocusNode? focusNode,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
} else { } else {
return CustomAppTextField( return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
isRequired: isRequired, isRequired: isRequired,

View File

@ -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 TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool autocorrect;
final bool enableSuggestions;
final GlobalKey<FormFieldState<String>>? formFieldKey;
const CustomAppTextField({ const CustomAppTextField({
super.key, super.key,
@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget {
this.textInputAction, this.textInputAction,
this.onFieldSubmitted, this.onFieldSubmitted,
this.inputFormatters, this.inputFormatters,
this.autocorrect = true,
this.enableSuggestions = true,
this.formFieldKey,
}); });
@override @override
@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double fontHeightMultiplier = 1.2;
const double internalVerticalPadding = 16.0;
final double dynamicFieldHeight = widget.fieldHeight; final double dynamicFieldHeight = widget.fieldHeight;
// Indication « non éditable » : libellé + hint en gris.
final Color labelColor =
widget.enabled ? Colors.black87 : Colors.grey;
final Color hintColor = widget.enabled
? Colors.black54.withValues(alpha: 0.7)
: Colors.grey;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
widget.labelText, widget.labelText,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.labelFontSize, fontSize: widget.labelFontSize,
color: Colors.black87, color: labelColor,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
], ],
// Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait.
SizedBox( SizedBox(
width: widget.fieldWidth, width: widget.fieldWidth,
height: dynamicFieldHeight,
child: Stack( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: [ children: [
Positioned.fill( Positioned.fill(
@ -112,48 +124,63 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
Padding( Padding(
padding: padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: TextFormField( child: ConstrainedBox(
controller: widget.controller, constraints: BoxConstraints(
focusNode: widget.focusNode, minHeight:
obscureText: widget.obscureText, (dynamicFieldHeight - 16.0).clamp(24.0, double.infinity),
keyboardType: widget.keyboardType, ),
inputFormatters: widget.inputFormatters, child: TextFormField(
autofillHints: widget.autofillHints, key: widget.formFieldKey,
textInputAction: widget.textInputAction, controller: widget.controller,
onFieldSubmitted: widget.onFieldSubmitted, focusNode: widget.focusNode,
enabled: widget.enabled, obscureText: widget.obscureText,
readOnly: widget.readOnly, keyboardType: widget.keyboardType,
onTap: widget.onTap, autocorrect: widget.autocorrect,
style: GoogleFonts.merienda( enableSuggestions: widget.enableSuggestions,
fontSize: widget.inputFontSize, inputFormatters: widget.inputFormatters,
color: widget.enabled ? Colors.black87 : Colors.grey), autofillHints: widget.autofillHints,
validator: widget.validator ?? textInputAction: widget.textInputAction,
(value) { onFieldSubmitted: widget.onFieldSubmitted,
if (!widget.enabled || widget.readOnly) return null; enabled: widget.enabled,
if (widget.isRequired && readOnly: widget.readOnly,
(value == null || value.isEmpty)) { onTap: widget.onTap,
return 'Ce champ est obligatoire'; style: GoogleFonts.merienda(
} fontSize: widget.inputFontSize,
return null; color: Colors.black87),
}, validator: widget.validator ??
decoration: InputDecoration( (value) {
hintText: widget.hintText, if (!widget.enabled || widget.readOnly) return null;
hintStyle: GoogleFonts.merienda( if (widget.isRequired &&
fontSize: widget.inputFontSize, (value == null || value.isEmpty)) {
color: Colors.black54.withOpacity(0.7)), return 'Ce champ est obligatoire';
border: InputBorder.none, }
contentPadding: EdgeInsets.zero, return null;
suffixIcon: widget.suffixIcon != null },
? Padding( decoration: InputDecoration(
padding: const EdgeInsets.only(right: 0.0), hintText: widget.hintText,
child: Icon(widget.suffixIcon, hintStyle: GoogleFonts.merienda(
color: Colors.black54, fontSize: widget.inputFontSize,
size: widget.inputFontSize * 1.1), color: hintColor),
) border: InputBorder.none,
: null, contentPadding: EdgeInsets.zero,
isDense: true, 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 bool enableHoverEffect; // Pour activer/désactiver l'effet de survol
final Color initialShadowColor; // Nouveau paramètre final Color initialShadowColor; // Nouveau paramètre
final Color hoverShadowColor; // Nouveau paramètre final Color hoverShadowColor; // Nouveau paramètre
/// `Clip.none` : ne pas découper le child (ex. trou du cadre PNG par-dessus la photo).
final Clip clipBehavior;
const HoverReliefWidget({ const HoverReliefWidget({
required this.child, required this.child,
@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget {
this.enableHoverEffect = true, // Par défaut, l'effet est activé this.enableHoverEffect = true, // Par défaut, l'effet est activé
this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15) this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15)
this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3) this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3)
this.clipBehavior = Clip.antiAlias,
super.key, super.key,
}); });
@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: elevation, elevation: elevation,
shadowColor: shadowColor, shadowColor: shadowColor,
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias, clipBehavior: widget.clipBehavior,
child: widget.child, child: widget.child,
), ),
); );
@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State<HoverReliefWidget> {
elevation: widget.initialElevation, // Utilise l'élévation initiale elevation: widget.initialElevation, // Utilise l'élévation initiale
shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
clipBehavior: Clip.antiAlias, clipBehavior: widget.clipBehavior,
child: widget.child, child: widget.child,
); );
} }

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:math' as math; import 'dart:math' as math;
@ -76,6 +79,19 @@ class PersonalInfoFormScreen extends StatefulWidget {
} }
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> { class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// Ordre de tabulation explicite (étape 1 / parent 2, etc.)
static const double _focusSecondPersonToggle = 1;
static const double _focusSameAddressToggle = 2;
static const double _focusLastName = 10;
static const double _focusFirstName = 11;
static const double _focusPhone = 12;
static const double _focusEmail = 13;
static const double _focusAddress = 14;
static const double _focusPostal = 15;
static const double _focusCity = 16;
static const double _focusNavPrevious = 100;
static const double _focusNavNext = 101;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late TextEditingController _lastNameController; late TextEditingController _lastNameController;
late TextEditingController _firstNameController; late TextEditingController _firstNameController;
@ -89,13 +105,22 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
bool _sameAddress = false; bool _sameAddress = false;
bool _fieldsEnabled = true; bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_lastNameController = TextEditingController(text: widget.initialData.lastName); _lastNameController = TextEditingController(text: widget.initialData.lastName);
_firstNameController = TextEditingController(text: widget.initialData.firstName); _firstNameController = TextEditingController(text: widget.initialData.firstName);
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone)); _phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
_emailController = TextEditingController(text: widget.initialData.email); _emailController = TextEditingController(
text: normalizeEmailText(widget.initialData.email),
);
_addressController = TextEditingController(text: widget.initialData.address); _addressController = TextEditingController(text: widget.initialData.address);
_postalCodeController = TextEditingController(text: widget.initialData.postalCode); _postalCodeController = TextEditingController(text: widget.initialData.postalCode);
_cityController = TextEditingController(text: widget.initialData.city); _cityController = TextEditingController(text: widget.initialData.city);
@ -109,10 +134,147 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
_sameAddress = widget.initialSameAddress ?? false; _sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields(); _updateAddressFields();
} }
if (widget.mode == DisplayMode.editable) {
_lastNameFocus = FocusNode();
_firstNameFocus = FocusNode();
_cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
}
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onCityFocusChange() {
if (_cityFocus == null || _cityFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_cityController);
}
void _onEmailFocusChange() {
if (_emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
// Pas derreur « obligatoire » au blur si le champ est encore vide :
// évite un message dès lactivation du parent 2 (focus / rebuild) et
// la soumission du formulaire valide toujours.
if (normalizeEmailText(_emailController.text).isEmpty) {
return;
}
_emailFormKey.currentState?.validate();
});
}
String? _validateRequiredFrenchPhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String? _validateRequiredEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire.';
}
return validateEmail(value, allowEmpty: true);
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredFrenchPhone(value);
}
String? _validateEmailIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredEmail(value);
}
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
String? _validateAddressLineIfManualEntry(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return null;
}
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
String? _validatePostalCodeField(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
return validateFrenchPostalCode(value, allowEmpty: false);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
} }
@override @override
void dispose() { void dispose() {
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange);
_emailFocus?.removeListener(_onEmailFocusChange);
_lastNameFocus?.dispose();
_firstNameFocus?.dispose();
_cityFocus?.dispose();
_emailFocus?.dispose();
_lastNameController.dispose(); _lastNameController.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
@ -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() { void _handleSubmit() {
if (widget.mode == DisplayMode.editable) {
_applyPersonNameFormat(_lastNameController);
_applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController);
}
// Parent 2 désactivé : pas de contrôle sur les champs (désactivés à lécran).
if (widget.showSecondPersonToggle && !_hasSecondPerson) {
widget.onSubmit(
PersonalInfoData(),
hasSecondPerson: false,
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
);
return;
}
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) { if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
final data = PersonalInfoData( final data = PersonalInfoData(
firstName: _firstNameController.text, firstName: _firstNameController.text,
lastName: _lastNameController.text, lastName: _lastNameController.text,
phone: normalizePhone(_phoneController.text), phone: normalizePhone(_phoneController.text),
email: _emailController.text, email: normalizeEmailText(_emailController.text),
address: _addressController.text, address: _addressController.text,
postalCode: _postalCodeController.text, postalCode: _postalCodeController.text,
city: _cityController.text, city: _cityController.text,
@ -169,75 +368,84 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0), padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Column( child: _wrapScrollBodyIfEditable(
mainAxisAlignment: MainAxisAlignment.center, config: config,
children: [ child: Column(
Text( mainAxisAlignment: MainAxisAlignment.center,
widget.stepText, children: [
style: GoogleFonts.merienda( Text(
fontSize: config.isMobile ? 13 : 16, widget.stepText,
color: Colors.black54, 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,
),
),
),
],
), ),
), ),
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, overflow: TextOverflow.ellipsis,
), ),
), ),
Transform.scale( _orderedFocus(
scale: config.isMobile ? 0.85 : 1.0, enabled: config.isEditable,
child: Switch( order: _focusSecondPersonToggle,
value: _hasSecondPerson, child: Transform.scale(
onChanged: (value) { scale: config.isMobile ? 0.85 : 1.0,
setState(() { child: Switch(
_hasSecondPerson = value; value: _hasSecondPerson,
_fieldsEnabled = value; onChanged: (value) {
}); setState(() {
}, _hasSecondPerson = value;
activeColor: Theme.of(context).primaryColor, _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, overflow: TextOverflow.ellipsis,
), ),
), ),
Transform.scale( _orderedFocus(
scale: config.isMobile ? 0.85 : 1.0, enabled: config.isEditable,
child: Switch( order: _focusSameAddressToggle,
value: _sameAddress, child: Transform.scale(
onChanged: _fieldsEnabled ? (value) { scale: config.isMobile ? 0.85 : 1.0,
setState(() { child: Switch(
_sameAddress = value; value: _sameAddress,
_updateAddressFields(); onChanged: _fieldsEnabled ? (value) {
}); setState(() {
} : null, _sameAddress = value;
activeColor: Theme.of(context).primaryColor, _updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
),
), ),
), ),
], ],
@ -606,6 +835,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -616,6 +847,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
), ),
], ],
@ -633,7 +866,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -645,6 +880,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
), ),
], ],
@ -658,6 +898,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
@ -670,9 +914,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: 'Code postal', hint: '5 chiffres',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -684,6 +931,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
), ),
], ],
@ -852,6 +1104,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _lastNameController, controller: _lastNameController,
hint: 'Votre nom de famille', hint: 'Votre nom de famille',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -862,6 +1116,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _firstNameController, controller: _firstNameController,
hint: 'Votre prénom', hint: 'Votre prénom',
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -873,7 +1129,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters, inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -885,6 +1143,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre adresse e-mail', hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -895,6 +1158,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _addressController, controller: _addressController,
hint: 'Numéro et nom de votre rue', hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -903,9 +1170,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
config: config, config: config,
label: 'Code Postal', label: 'Code Postal',
controller: _postalCodeController, controller: _postalCodeController,
hint: 'Code postal', hint: '5 chiffres',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -916,6 +1186,11 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
controller: _cityController, controller: _cityController,
hint: 'Votre ville', hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress, enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
), ),
], ],
); );
@ -930,6 +1205,10 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
TextInputType? keyboardType, TextInputType? keyboardType,
bool enabled = true, bool enabled = true,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
double? focusOrder,
FocusNode? focusNode,
GlobalKey<FormFieldState<String>>? formFieldKey,
String? Function(String?)? fieldValidator,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
@ -942,9 +1221,12 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: displayValue, value: displayValue,
); );
} else { } else {
// Mode éditable : style adapté mobile/desktop final effectiveKeyboardType = keyboardType ?? TextInputType.text;
return CustomAppTextField( final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige, style: CustomAppTextFieldStyle.beige,
@ -952,10 +1234,23 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
fieldHeight: config.isMobile ? 45.0 : 53.0, fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0, labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0, inputFontSize: config.isMobile ? 14.0 : 20.0,
keyboardType: keyboardType ?? TextInputType.text, keyboardType: effectiveKeyboardType,
autocorrect: !isEmail,
enableSuggestions: !isEmail,
autofillHints: isEmail ? const [AutofillHints.email] : null,
textInputAction: isEmail ? TextInputAction.next : null,
enabled: enabled, enabled: enabled,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
validator: fieldValidator,
isRequired: fieldValidator == null,
); );
if (focusOrder != null) {
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();