From fdd1e06e77702477624aee19dc425127c92a98cd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Apr 2026 18:07:24 +0200 Subject: [PATCH] =?UTF-8?q?[#101]=20[Frontend]=20Inscription=20parent=20?= =?UTF-8?q?=E2=80=94=20API,=20soumission=20et=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + backend/.env.example | 8 + backend/src/main.ts | 73 ++- backend/src/modules/mail/mail.service.ts | 38 ++ backend/src/routes/auth/auth.module.ts | 2 + backend/src/routes/auth/auth.service.ts | 98 +++- .../dto/dossier-famille-complet.dto.ts | 12 + backend/src/routes/parents/parents.service.ts | 35 +- database/BDD.sql | 2 +- docker-compose.yml | 11 + docs/23_LISTE-TICKETS.md | 13 +- frontend/assets/images/photo_frame.png | Bin 0 -> 160958 bytes frontend/lib/models/dossier_unifie.dart | 35 +- .../lib/models/user_registration_data.dart | 37 +- .../creation/admin_create.dart | 47 +- .../creation/gestionnaires_create.dart | 47 +- .../auth/am_register_step1_screen.dart | 32 +- .../auth/am_register_step2_screen.dart | 33 +- .../auth/am_register_step3_screen.dart | 13 +- frontend/lib/screens/auth/login_screen.dart | 239 ++++++--- .../auth/parent_register_step1_screen.dart | 35 +- .../auth/parent_register_step2_screen.dart | 21 +- .../auth/parent_register_step3_screen.dart | 189 ++++--- .../auth/parent_register_step4_screen.dart | 14 +- .../auth/parent_register_step5_screen.dart | 73 ++- frontend/lib/services/api/api_config.dart | 45 +- frontend/lib/services/auth_service.dart | 131 ++++- frontend/lib/services/user_service.dart | 23 +- frontend/lib/utils/data_generator.dart | 70 --- frontend/lib/utils/email_utils.dart | 57 ++ frontend/lib/utils/name_format_utils.dart | 32 ++ .../utils/parent_registration_payload.dart | 284 ++++++++++ frontend/lib/utils/phone_utils.dart | 124 ++++- frontend/lib/utils/postal_utils.dart | 19 + .../lib/widgets/admin/parametres_panel.dart | 16 +- .../admin/relais_management_panel.dart | 9 +- .../widgets/admin/validation_am_wizard.dart | 16 +- .../admin/validation_family_wizard.dart | 27 +- frontend/lib/widgets/app_custom_checkbox.dart | 87 ++-- frontend/lib/widgets/child_card_widget.dart | 490 +++++++++++++----- .../widgets/common/auth_network_image.dart | 130 +++++ .../lib/widgets/custom_app_text_field.dart | 117 +++-- frontend/lib/widgets/email_text_field.dart | 219 ++++++++ frontend/lib/widgets/french_phone_field.dart | 127 +++++ frontend/lib/widgets/hover_relief_widget.dart | 7 +- .../widgets/personal_info_form_screen.dart | 489 +++++++++++++---- .../register-parent-durand-rousseau-test.mjs | 156 ++++++ scripts/register-parent-lecomte-test.mjs | 102 ++++ scripts/register-parent-martin-test.mjs | 126 +++++ 49 files changed, 3181 insertions(+), 830 deletions(-) create mode 100644 frontend/assets/images/photo_frame.png delete mode 100644 frontend/lib/utils/data_generator.dart create mode 100644 frontend/lib/utils/email_utils.dart create mode 100644 frontend/lib/utils/name_format_utils.dart create mode 100644 frontend/lib/utils/parent_registration_payload.dart create mode 100644 frontend/lib/utils/postal_utils.dart create mode 100644 frontend/lib/widgets/common/auth_network_image.dart create mode 100644 frontend/lib/widgets/email_text_field.dart create mode 100644 frontend/lib/widgets/french_phone_field.dart create mode 100644 scripts/register-parent-durand-rousseau-test.mjs create mode 100644 scripts/register-parent-lecomte-test.mjs create mode 100644 scripts/register-parent-martin-test.mjs diff --git a/.gitignore b/.gitignore index 175b613..fb0f402 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ .env.*.local # IDE +.cursor/ .idea/ .vscode/ *.swp diff --git a/backend/.env.example b/backend/.env.example index 67081bd..cfd6a65 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development +# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU. +# Local : laisser vide → ./uploads/photos (relatif au cwd du processus). +# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml). +# UPLOAD_PHOTOS_DIR= +# +# Reverse proxy : si Nginx devant l’API, augmenter la taille du corps (ex. client_max_body_size 16m;). +# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+. + # Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front # LOG_API_REQUESTS=true diff --git a/backend/src/main.ts b/backend/src/main.ts index 6c62287..c5754bf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,26 +1,87 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; +import * as path from 'path'; +import * as express from 'express'; + +/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */ +function setStaticImageCorsHeaders(res: express.Response): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); +} + +const staticImageServeOptions = { + index: false, + fallthrough: true, + setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res), +}; + +/** Préflight si le navigateur interroge OPTIONS sur les médias. */ +function uploadsCorsPreflight( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + if (req.method === 'OPTIONS') { + setStaticImageCorsHeaders(res); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader('Access-Control-Max-Age', '86400'); + res.status(204).end(); + return; + } + next(); +} + +/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */ +function resolveUploadsFilesystemRoot(): string { + const raw = process.env.UPLOAD_PHOTOS_DIR?.trim(); + const photosDir = raw + ? path.isAbsolute(raw) + ? raw + : path.resolve(process.cwd(), raw) + : path.join(process.cwd(), 'uploads', 'photos'); + return path.dirname(photosDir); +} async function bootstrap() { - const app = await NestFactory.create(AppModule, - { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log', 'debug', 'verbose'], + }); - // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) - app.useGlobalInterceptors(new LogRequestInterceptor()); + // Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué. + app.useBodyParser('json', { limit: '15mb' }); + app.useBodyParser('urlencoded', { extended: true, limit: '15mb' }); - // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production + // CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes. app.enableCors({ - origin: true, // Autorise toutes les origines (dev) - à restreindre en prod + origin: true, // Reflète l’Origin (dev / prod) — routes API + cookies JWT methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], credentials: true, }); + const expressApp = app.getHttpAdapter().getInstance(); + const uploadsRoot = resolveUploadsFilesystemRoot(); + + // Photos : chemins stockés en base type /uploads/photos/... + // En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs). + expressApp.use('/uploads', uploadsCorsPreflight); + expressApp.use('/api/v1/uploads', uploadsCorsPreflight); + + app.useStaticAssets(uploadsRoot, { + prefix: '/uploads/', + ...staticImageServeOptions, + }); + expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions)); + + // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) + app.useGlobalInterceptors(new LogRequestInterceptor()); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 6a1e872..df5eec9 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -98,6 +98,44 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de réception inscription parent : demande enregistrée + n° de dossier. + * L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade. + */ + async sendParentRegistrationPendingEmail( + to: string, + prenom: string, + nom: string, + numeroDossier: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`; + const html = ` +
+

Bonjour ${safe(prenom)} ${safe(nom)},

+

Nous avons bien enregistré votre demande de création de compte sur ${safe(appName)}.

+

Numéro de dossier : ${safe(numeroDossier)}

+

Votre dossier est en attente de validation par notre équipe. Vous recevrez un email lorsqu’il aura été traité.

+ +
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } + /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 2a4513a..34d4631 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, + MailModule, NumeroDossierModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 5d7d943..2665721 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,12 +1,13 @@ import { ConflictException, Injectable, + Logger, NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -22,20 +23,26 @@ import { Parents } from 'src/entities/parents.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; +import { MailService } from 'src/modules/mail/mail.service'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly usersService: UserService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, + private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @@ -211,7 +218,16 @@ export class AuthService { const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { + parent1: Users; + parent2: Users | null; + enfants: Children[]; + tokenCreationMdp: string; + tokenCoParent: string | null; + }; + + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const parent1 = manager.create(Users, { @@ -321,6 +337,25 @@ export class AuthService { } } + // Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119) + const presentationTrim = dto.presentation_dossier?.trim(); + const dossierFamilleEnt = manager.create(DossierFamille, { + numero_dossier: numeroDossier, + presentation: presentationTrim || undefined, + statut: StatutDossierType.ENVOYE, + parent: entiteParent, + }); + const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt); + for (const enfantEnregistre of enfantsEnregistres) { + await manager.save( + DossierFamilleEnfant, + manager.create(DossierFamilleEnfant, { + id_dossier_famille: dossierFamilleSaved.id, + id_enfant: enfantEnregistre.id, + }), + ); + } + return { parent1: parent1Enregistre, parent2: parent2Enregistre, @@ -329,6 +364,38 @@ export class AuthService { tokenCoParent, }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } + + const numeroDossier = resultat.parent1.numero_dossier ?? ''; + + try { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', + numeroDossier, + ); + if (resultat.parent2) { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); + } return { message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', @@ -336,6 +403,7 @@ export class AuthService { co_parent_id: resultat.parent2?.id, enfants_ids: resultat.enfants.map(e => e.id), statut: StatutUtilisateurType.EN_ATTENTE, + numero_dossier: numeroDossier, }; } @@ -404,7 +472,9 @@ export class AuthService { const dateConsentementPhoto = dto.consentement_photo ? new Date() : undefined; - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { user: Users }; + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const user = manager.create(Users, { @@ -443,6 +513,12 @@ export class AuthService { return { user: userEnregistre }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).'); + } + throw err; + } return { message: @@ -464,7 +540,12 @@ export class AuthService { const extension = correspondances[1]; const tamponImage = Buffer.from(correspondances[2], 'base64'); - const dossierUpload = '/app/uploads/photos'; + const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim(); + const dossierUpload = rawDir + ? path.isAbsolute(rawDir) + ? rawDir + : path.resolve(process.cwd(), rawDir) + : path.join(process.cwd(), 'uploads', 'photos'); await fs.mkdir(dossierUpload, { recursive: true }); const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; @@ -575,4 +656,13 @@ export class AuthService { token: user.token_reprise, }; } + + /** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */ + private isPostgresUniqueViolation(err: unknown): boolean { + if (!(err instanceof QueryFailedError)) { + return false; + } + const code = (err.driverError as { code?: string } | undefined)?.code; + return code === '23505'; + } } diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts index 29437cf..d051b0d 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -42,6 +42,18 @@ export class DossierFamilleEnfantDto { due_date?: Date; @ApiProperty({ enum: StatutEnfantType }) status: StatutEnfantType; + + @ApiProperty({ + required: false, + description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)', + }) + photo_url?: string; + + @ApiProperty({ + required: false, + description: 'Consentement affichage photo (colonne consentement_photo)', + }) + consent_photo?: boolean; } /** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index f4d6f53..ae9bedd 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -17,6 +17,7 @@ import { DossierFamilleParentDto, DossierFamilleEnfantDto, } from './dto/dossier-famille-complet.dto'; +import { Children } from 'src/entities/children.entity'; @Injectable() export class ParentsService { @@ -209,6 +210,20 @@ export class ParentsService { } /** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */ + private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto { + return { + id: child.id, + first_name: child.first_name, + last_name: child.last_name, + genre: child.gender, + birth_date: child.birth_date, + due_date: child.due_date, + status: child.status, + photo_url: child.photo_url ?? undefined, + consent_photo: child.consent_photo, + }; + } + private normalizeParentIds(parentIds: unknown): string[] { if (Array.isArray(parentIds)) return parentIds.map(String); if (typeof parentIds === 'string') { @@ -257,15 +272,7 @@ export class ParentsService { if (p.parentChildren) { for (const pc of p.parentChildren) { if (pc.child && !enfantsMap.has(pc.child.id)) { - enfantsMap.set(pc.child.id, { - id: pc.child.id, - first_name: pc.child.first_name, - last_name: pc.child.last_name, - genre: pc.child.gender, - birth_date: pc.child.birth_date, - due_date: pc.child.due_date, - status: pc.child.status, - }); + enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child)); } } } @@ -275,6 +282,16 @@ export class ParentsService { } } + // Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif) + if (dossierFamille?.enfants?.length) { + for (const dfe of dossierFamille.enfants) { + const c = dfe.enfant; + if (c && !enfantsMap.has(c.id)) { + enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c)); + } + } + } + const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({ user_id: p.user_id, email: p.user.email, diff --git a/database/BDD.sql b/database/BDD.sql index 7a7ab17..670336f 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -8,7 +8,7 @@ DO $$ BEGIN CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN - CREATE TYPE genre_type AS ENUM ('H', 'F'); + CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); diff --git a/docker-compose.yml b/docker-compose.yml index a4fad4d..dcfa74b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,10 @@ services: NODE_ENV: ${NODE_ENV} LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} + # Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads) + UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos} + volumes: + - backend_uploads:/app/uploads/photos depends_on: - database labels: @@ -66,6 +70,12 @@ services: - "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver" - "traefik.http.routers.ptitspas-api.priority=20" - "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000" + # Fichiers statiques photos (URL relatives /uploads/...) — priorité > front (10) + - "traefik.http.routers.ptitspas-uploads.rule=Host(\"app.ptits-pas.fr\") && PathPrefix(\"/uploads\")" + - "traefik.http.routers.ptitspas-uploads.entrypoints=websecure" + - "traefik.http.routers.ptitspas-uploads.tls.certresolver=leresolver" + - "traefik.http.routers.ptitspas-uploads.priority=18" + - "traefik.http.routers.ptitspas-uploads.service=ptitspas-api" networks: - ptitspas_network - proxy_network @@ -94,6 +104,7 @@ services: volumes: postgres_data: + backend_uploads: networks: ptitspas_network: diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index b9eb010..9f12ad3 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -1,7 +1,7 @@ # 🎫 Liste Complète des Tickets - Projet P'titsPas -**Version** : 1.6 -**Date** : 25 Février 2026 +**Version** : 1.7 +**Date** : 25 Mars 2026 **Auteur** : Équipe PtitsPas **Estimation totale** : ~208h @@ -9,7 +9,7 @@ ## 🔗 Liste des tickets Gitea -**Les numéros de section dans ce document = numéros d’issues 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 d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API). | Gitea # | Titre (dépôt) | Statut | |--------|----------------|--------| @@ -88,10 +88,10 @@ | 103 | Numéro de dossier – backend | Ouvert | | 104 | Numéro de dossier – frontend | Ouvert | | 105 | Statut « refusé » | Ouvert | -| 106 | Liste familles en attente | Ouvert | -| 107 | Onglet « À valider » + listes | Ouvert | +| 106 | Liste familles en attente | ✅ Fermé | +| 107 | Onglet « À valider » + listes | ✅ Fermé | | 108 | Validation dossier famille | Ouvert | -| 109 | Modale de validation | Ouvert | +| 109 | Modale de validation | ✅ Fermé | | 110 | Refus sans suppression | Ouvert | | 111 | Reprise après refus – backend | Ouvert | | 112 | Reprise après refus – frontend | Ouvert | @@ -100,6 +100,7 @@ | 115 | Rattachement parent – backend | Ouvert | | 116 | Rattachement parent – frontend | Ouvert | | 117 | Évolution du cahier des charges | Ouvert | +| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé | *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* diff --git a/frontend/assets/images/photo_frame.png b/frontend/assets/images/photo_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..9d9beebb515ac3de08a79f6acd688c16c9a2756a GIT binary patch literal 160958 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

1u0hi30nXY-`IH85a{VJLDj(U{Fdz9rtZ?!f&h^ zwf_&8`j%6F6e`UMjQT)~lOAdF*s|{dr?L0_mdwN-nZUs7qKXTPNyypY>^d(<9C-My z_mp!MIt&Cb95$z@CntSxhX?5z65%!`JlMR8QO(2Y{AjrtS%vQotUW}r zJ>G{XrvJ0U^@I;y_YRhkjX?_60Jj$gAZbx?(XExT#Kx+&@D=+Jo1vl6o!iyUWCFi4 zHg@uV!B@bL1;NVCO*>Izl81jm2(kMSj=Nw6xn(f6u%K#essG_#>M3)hOLW1 zLULo{m;R;$k*`W?h4XoNn`qVSEhJoA*xNHRWO5lp5P?YqyzWRuxg4wBiH!VmeOWwC zF<OCG~kNw8$X?2UgIa*UNE&@xk1B`p=B}ZgR zpnP4v0KOVRH5ueb2UOk{Yc+zjXa>jQ_=U%wn1Eqy3#an|m5CBenSKkhFmdSPBfPhl zHjSpH;x%GwwcZ=QSI6J*{8mlKcUAf1)>5O(Ef*4HLknF3+ipgDXrS5fQnL{MGRk}+h$3`{hE#&<0gxIci(Xxx_xL>CjZeB*o~ z#7J!Ey>YDMw_6Br3)Gigh-+7iD~EU&ns36>WD%!(@o16@|Bkd zh8glu{FACN?=yp%T&fmm8ud9FDd{nRzBSK3c+*wty!)fg>uegF$?={nG(_Og@VJDx z6086_b3a$g?;%O4!QEoh-)m0qTy58*{O(Kq`X5-LTi>)1i73QBy`9#DS~#nGP*>0i zOGj^VHZg&wtD*z#4n046msVHD^jjH2K>1!#N6+;qRCZSSAC)iFPhXZ#mOOB`$`{0k zY==UFes{dA4H_4)$ zA7YCYjDE)(7}_4X(o_Ds)vVR8c-SOrk;x`zX3Aw6!s~qAVZc0_ppSH0r8rIqFqs5Y zb=z2?qmf4X3w>XHR7YJ`PRsK=gK``p4&17BxqK#m|9-B)z|7h)>1#WW!PjmBnklD@ z7Te;H)YO7)Paa^$y4JT(oSU6n9B}rX>i=Q1j_;QLa0wA5FB7=!Zc_O9VzG^DBS?7s z*kS5oiiw$>UzpTHNl8~ZlnvLlVQ%YhYD2BX4sDw9Gb(CvTlWk{fRD{Ld2mGA*#&&2 zFok2@$%B>YgaA|F57({*wsX`}XqKJ#(+ua=v2W-CFY_ge3SR>Ok5_-Sx!L&ZB(!pc zkKw||*xgIc;WD!0abw!-S7&&hsbK@c)vc{xb8>PhBge%&?|@N3R7~{AqR)2sMB({G z+WWAOZM#U7qf$zS%%1ZuHY>FUKv^eflc?2g%>xd;WPGxH&PB)BnN0B`sW6SzaZrO3 z;2$B74VWeHN8AUA^4@2K)>Zq$pLU)3E=>Uwor*b0>5(ss=HDk37yxPoN+QYw&}YXz zKhxG5Pw>$~|GBz;SF~5}o{SSC^$KP&tC-=HF00&+$C3E+C@srxGxhde8 zk!7PGO&hRZUVMgzhJxbG&qKi_rR0P>Jux03pEfleNtE!Tclt%`g9_-X=EG)e^Nu;J zNzt2dHL(6n62A%vajf25C(Jy=4epER+i_H~Q>sX#vueTrvRi;`84)YUFEDhYk2}18 zu@?r*M4&8Jo1F&6In&-v*lZP6@u~V>8YtzAJZ&)$`R+M=U$4x}$9`&wiiu4rP)Ww% z^GF}fSCwoVZ#z%5NtRocOFAiAeZ3UQE)d<>76EG$B`GbXe_L64%&PJERrADx2nU#t z?6^HaLG`qF&ASqFDh^pU!(72SzAqXe&%^Y+z{C9D1aJ;+`p@q20baOeGIgh^ZWj?9 zX{20CEMPL)=OpliUESom@d|Z1Se?aV-bj(HA;TT?F)lMR=9o^FF+HkOM=>ym?*$EA zy5GcO(UDr6y4H9;3(w1o<^RH-Um~%Ysli*oz_WBC#U5MBNWY-sqa!#jyN$4nHD2;CS=cW!ktnza{nNZZ@PL~Rp| zU?ddQw&HmY4%X?5sDzSuu&Vz|?Qs!oR%Yb@6R&8AhJhaMw&qFGXnBIOnNr1bkT4T6 zK5qMXQ~`CLpYJVK7Dj{@#?eC5<2cgXCE^!ue2H0>!g~`dzsAGHbTOUi_vW@K$SCS} zKOxY+fA+*%l95JElJ_<0L#%;bNr8t4mxfwrl==47M`Tz@9cx}u6ebmFHu2C$uXrjA zeF5fYTkhS;VPIoN2_{p#WqssTQ+r)(8hCo%}VaSbHuD4Q>ow&W9-B3T1$~a@~PWe#McRk-d4) z&YlVBkn*UEtPn+_;c^UaSug7+@sJRv+2z>cJqjFje372prW!|GKEAKO{SKE3H_THY zYSf^0?zMzB+uwmsOhgSu-9}YUQmfQ@{)9i$v*2839J=nl0d(U6`g*!zKA#AHc}m<+ zDHSV+?hk`EXxP+UQU(rS)&rr}lJSFbAo2Mbywi1qL!-fz54eZK?~zdP10gb^0j5Hf z^-o7g$zUVm zh)>7izOMF8MwZ0dYZ-=Edo@X70%R3$j>QS~5Stvn#i$+*oSu)pvjy z*p}aYS+!l)SL#mrx(9hHf9wLKR=FUvhDcek1Q9ua1q%f*h>23E!NA6Wtw3uGa7L`C zSxdKsT!Am zLOluz25eqTgpiE&hwt9dK%L@kOW2Qs932%w0cXk*u*q1+3{zZE0`Ll;awdX-k?c7! zfiWp*qvx(PGb5+q4D+;9l5}u)HNmXiN>C&wgK*8fP@aM?I)Ds0Kfl(_`AmrsPim|Zjl>LK70ZKiscqtau%nCuNhfz%1D+Ci;^Zu zu)@OTa%$toOm*pgx=pSl`p(JwJqHiWB{4ls)h&MK{poC0MLs@0hJHjuR5TxnW5l5F z;Mm)N-9pFqO|oLs;y$JLFsC+*I=eStqiI~E^Cx07SSB96;HXg_b)xiWC|7s4GCv zk_87oH%P?Bh7RD2#V004SB_@BLrvU%8Zi2pmW?CfY717WhAJKkl@~rmL{kC+^nD{E z5zLL6u<)@1X4=A_#n6mc;~6u?!e&Kh;(;%cc(~qgEUBYNjFHtdET?u z$1nfk+QaO9-&f8}1Y(B9C+A?$Y*As4ZaJ(#8aSu~O0k$5QCM`o(wfOE9-ZC_!bnkQ zufo8Q#Ugz|>!1+$>C})9u9%s2h-%h9H9uKkGxspZx^mQ41mTdNgiysRD^lUfYq_w# zhzkJv(%IPt)rN*S{m_2s#T^l7c-WD#$>2$=qHT>h;L81tEQpgI z0fk7-F7UbMdI$WcN3$^$8!)}Sy92`PNs?Iv;?GYA%fU>gJaf(f zRO>QWQBB#FR_kbA7F4=Dp;mfk9j<{qoPh7RlGIK}po*wGb|P_`LrG~xID_|Z?DEJKE6@An&RL2 zEPQM(kNo|+LYq$46#ka}zyy_ccjw;wb`UYo(06UR={$e0hzgTc^BF|+?sVD4;@AO& zsjGt0h4#&Ds>Vh_zgI6_K=FS5I)Cb2W*L$I(b(%9TWAAf0qq;}JB6&00> z!R1k85^iv-Zs&fApx&YC=7jAo%iq`-+1ZQr5LbxBXSSL~URo|{WZ>e2Lfo?;gxI;n z+3c8xub#*V67_^cuX1rZkW*8H%~^2)NknwRGzGgmREeY0YhL(30I$Q3ls+VKh%#!FVXZtX(l;g~x6nwUVhm1EC56 zMh7@3difP=Z6kbWznNZb5)61MoUyoUVtEJ%8f zpoZ%6*XTcXMm;In&*FkjbGu?<+YV(!Nm%i+w^4(3K8pC|0VYPFkdatGJm}GBuUoAR zAg46S)jso(=?is5Rl$D8f<<|RCk(r#H=iGYuR7(jpwQ(i1t&yqZk{oyyG>1DT&6rJw(;EO-?uwI5< z%+fxXFjEPLvLhxl=L6Ftj9SdZFmF^&H`kov zd?DAfC`Cm@CRPqm@iQN{kb+e!B&C7g8ZuN#BrFx#IHmnKru`{2Oms0>rdS}TgSAb} z#b-k@0_7TuFcnIgDFK|Y{jb&G?izm&p8#TMLy|#+-q!x3zGY9^UW%Wa`)6GXBs3S3 z3QX)TcYw3Ay93P?nm2`7%@2)|j<*4Ua;dg7^2+ z@l$0-@DBL8)Rg;Zj&l9F=`tQGg z+8J31(%SPEH0Z+{dDYcyy4{YjG+OaUh+daZ9S$qmTT&{Odl2$gf;Wg|!rYbuB5C;e;!>oBE1 zkP8E4fU!nlDpw*fxpHy3-XB`6K!Xa551ly?Q)b0>%FjZ6e8LcJ&E(Kd5TB40u~!5l zit^0J&Qzbw5uS(~pi#@$+Uk1l3W=H2M#G57v-{3p+ARE`^gYi;V#nmy{CGHOcSc4Q+m~cSTOVC?pN2pS@ZLQu87- z`fQqe&;C&Z=$BEe2QvKNBN1sjm1|?A!{fCaaCuaf>4!HpX)`A(g&EdJO3iRDRnZ#? zE{4rUIE{~wo34j^(B;&3+Tfv;3%}m;SjLJb%9AJvQ<_bf4G#5CGjtt}|<}+GgpDPY5QdJfuB7~WNhN(Z~5vOwZCMX{)8nrPpOP@L<#{OUZTi(3#>`b&g^4D zS(OpAvdrQ6vAn?#)X&}asC~S$88wCj1BY$-$IhIwq|N;Z_kU-DkyQI z{o3V)A*BKCEWqgg{`kGhxPd?|mbBinUh%_3k+X9{QE59a0F}tF;uSQsliD<*IMu^F z$`!x(z6?*bbi7y~d8{)sv&(2(VgQ{+)C4G;VgfR$1mqxq8FZ^8)|_)5866AOu9WM! z7#z1xOC%SD9%V1iQ`J$yLAczunU7ZcML?0lfn?1?`BL*3_v z6&g!fx+?IMS~w7zkHsc0H+wj|0lnqm98h&em;l=Jq%4$rS-9rmNC;z4+94xgrs%We zlc|m=2BdR(PCu|O*{NB02-Oy81Y=~pIuK~`vz|KbX>e4ilOs7fIb~)1p+3r$LwJvv z2nlBQ_OvF=XJ~=wTx~V+$r#~!HrAe=2m-a4L;GVoK&3{oU^L(w z>zqy>_mZiy`Cw|H!2g*6c0{7Sl&`DP zUXL*5DEN0RpJmxKVS*$XsgSNjfk_f*QLOt}5^{X&9LJ&s5*9}JoV|#R_7tC)i+P`b z$ex-=z-ACrv?se$x5@>?>9l)8d&L#FeP#PwN^dRgM8ajj78fQ5h)wgFdDKMDf>AH`fK>sY&Ey-f zEvVnSqdvSh^Drk8aXXg+Y!wKIh;hmB$Op4HUu~^v>K&1nemZk_>eu>(4G@u$`GNB# zgTTm@6EOg^G>6Z^tn~HFW+~gv56k?^fmCMEU@|J|IZH&s;)Wa_DV(+sJpD1+9>8No#0D|n>CuAFbbp8V2OSks21|*t6D_bQ>88z(gNs8IuT*D! z11L=IcgNG6fw!b?rHYZIvPe`C7@UBWH(yK6)Z8V@ezhr`_p^N;priw+EqP8NSye4G z0K40E5vEEU_&c%hZs_atezF)@6TJcc?N?R zN~J+KfMU7No{xr>0nvN?^;b&^k!gAo5xTIT9mby;qiC56ijWDLLG&N?vc~F!RT!jh zcIya0i&c0yS5ay4&hb3|z<@OXF9p?{kn1+OiGei}fjFYj<74Jq7dIG_9OWW#k;UJH zoJowJeo0SlfHVV=n?Cv<9VyAm%bQqQlE6O>#wi60dOz{f(9*;iq@gyCA$Cjnb-npi z8Fi9;UDSuTvqBcnNw;o(aPspie~M?u$&U#FBsr(2Xn=j9zt#Cmd|ck(xtPFg;&4pw z$xT8)f%Jf^HBjG%it_Y^>APy5C$^pqhX<$%hM%51A7D*79#;k8a&!IrEG)FDqd>Sk zX46(XTQ`p;yoYfqIZ~fhBKK_O_&o2|b7{po#jLD5BhEzr^_;kx`PdT}H6xCVF_&vG z%V~~%0_z!Jl*__IP9Xda#Udq)wAHBv7+Xp{7H!}$-EHoEW=7G;sHKuGlyjeQEh;V1te2GqDr2Zpe{%|o29USAeY{VIgnRK} z#6uDMY|ko876w-E50GZWf$Wx?{c3MV1xyj~9Ba?`zxa&<2JH|S;vn-C!fE54K{6(X z=<-#YE$GpuG5)16z(5Fm$~1T=y}SSBG^S&tMeKX_hcsnDSk!cE#EZ4f)LS<)0uq=M zFjb$s326Ii8Clrn-0Ueg(>)b)tfA&Es!STUgjtM5#ARfJEc|78SZSs# zPUSEHktx)B$&s(|vup@GAT0c;Rgk-5W))&ho0*{5yP@W1LRLG0VMH%%ZpOqD<+Z^N zq5SGCQ5WdZVw-dib4ImsCkdabpPr5 zoSY&iRi_e2zqKLL7h(f=kp^ROT2_W60JoA5mxqj6(Y*O#T3e?d+I*9t!jm+tN+DE+ zGxzkuQ7cVkq}^*-Sqm7CQng#{BF6>dXOyP;@|!pNLeNj7!l0zODd#j3k)}EKaF~#w z*$q2n=PMZdQ2ioiO~YitQPJqVl(&U#KM_>yC6pslP0T&;Dn^iy;9=6Lv}G&)P;CK5 zav;fs>eGnh37dOAr$CHMC}v@aB^|*jfrl703I5}In#jd)+sW~}#MJY-n3IlEgLA|E zdC`xbI?znTerO+M;SrxcZSM|4GXp;j(y+pyP)8)7;OO|39} zqUrc=oY?$J30L)oq*E{qxSpsm2Ah|?8y!)6J@QlKHyIFKe_sHuD5U}@OY)-<@=Grr zzp$uky%ibYWHXPqNx51T4Na{Yh>@6dK#aFvxerU*z$6wzn`zCGgcQkQ&;7R@m&v(t$OQehc9O_eS1u2h~QvisIc-HI$=q& z3DBci>faOZ>zx;!PgnMxLCMPb3uTV4Pj~Ni*T^nzjkE%#P~oz0`5H(^dimDPaw)2E-u<-#+?YK=E(-Ant+TMJKIW#OxJ|ae$Go$7Z4@ z;Tv+i1`+FM-a;8ft-;iEDJJ2!FfU(r;M9Sfk3Clq4Yh~(s44;K=)N*%P7g9%nnM%~ z8G7G#TF+3l0jJoRBriz_TQl6hd?QY3M1vgk52=~cOVfTp*N$%0(c$s2AJ-t1ftt9e z5yOBf)6)-6PGR8?Q3HaxV{~3YPNe5La(BDU|0WNq;)T4Tjx?czY&C>ci7RQ{m1KX(>uU8a#p&$Rr9PUtvJb&4!48vCdz2jd*+>D8r;w|F)pM@B-Mn-|k!$M!=wx1R3kfske@A|zTK zG2D1XB+%cbrAwl9pJDG89R*CMjX}CopB-zLU%WD`sWiu>4lo$Z)db3|csdz1CaZrx`0Cz18Yy2Z?MO0K8_6B0QJC@@Km&W(P{{`aZ1n*y z9x-GDXcrqg&nM6L)YN`OD$r%mKoKli5F zT@<-`5^4Al3(XQ2fS^t5iROxnz%&$6+}Q96G$WscJ;WL`7#kIb5*4qJk%D0SuF`Ux zB-xW2j+Vb`X?^NBa++D&gJV8P>E#na{v3f!KCu{QtnELO56ub>8>eEyScl)AmE*5B zOauq!5`@O=oD4b|MfM z)y(=ddbwo*}J())5Mz&X1H1jJCmfipbD?FY3OT@GgqX{1Unn|X5>IOf{_~+fLk?!t~PQ) z=s>A*p)V_bFpTTxy({fx4}#~j)o@|Ep25kdr=2Z^hvmv{dB|wwW2?}^ix1I+B_4rJ zJqN~FaaES+5Q7JEC8GijwgQN#_u)a1Y`4E<_ne%S0)3`Ok}?T}4kXRx1uZd0>dGs^ z2b(M(=>S5x6h%QnrN&d4o>_s(^+mR6g~N9pqs9uSA{cW#I&~Z=R{m3a+!zS+KP`an zXvkTo`4|hJ$rz;Ec;Ew$P&{nVRPal7Ss`W<~Eb2 zG%7q~lmAbK6FOaKB=yguv~R`*o*M)i<{D#L>DQmhf{*jqOLmV$6cn;H`pgx!Bvg2c z4y{L`FOf!CpXMc52?8C-z~Cr=#q9T=`nMZQXaual@<4f{AkN^j=5YP%KM{w&r+QjC z+6A4!OIA%%sa-v^vqK~6B-Ga>w-iclRaQ9&hG5H*5qOJhr7Y0)n%3RB2kydYRBDCs zP>4=(3X29=38Fb?;G5PX$-uf1p)rk2P7IAqQ!!VbaPjg&0p}%m0Yj2n4U-#HSlkFd z>OsZ>B$c-Ku&Ct-hbXvAAhcme$mV@yqQV;kzM;kC;mE2YWF8I&?26{fzXXi^ zN9~4E6}q4l{bm!mf$6>De)5OMcKfD`^BB5$(SH{r7Ba(@qewlVcl;ZXjgJd2Akxn@Pu zpP7}It0U_wqgI&cG5h|=O>d0uhxu=H2c5puA|j~mxn2kg;;XR^ycqeqK|pqh-D(GV zjvOW~@}M~v@s^S9K)1xBfX{QgL|$bzd?5y6*L|SQdxH)KPM$!$_Y? z$borrLKaT~wM*p{elP$tBLLz+#wLO#sbsU%sW@t(p2b)CS439gbIl58BXHJSrvk0M z+4kO@=;&%|^8@$r=N1p-P0xRNVa428Lf>IN@u4ezP#*+RTLAy1@70UmB!oM!sR5pko6V7wds;FAp)bUroPEP8Pm8u(fas@xeY%a|-&$!3X+0U8!*N zoAZJ#wdui8+ChaTB_C}W>FDrb!r?lbJ=4I&AnIY03kAx0%JFJUMcTc65X2J zU}!Hz++oi!TSdH;h-}#A01ce*rKjj02|=h(GZ@#g)IbmEZ&Pemcfy9PUg+!xq67q~ z(~FC-0H8`AKQaR0?n}x5_I_)$)iYimkw^zBS(Idyw_+&!?i-9lFq)l@PtG1U@o(m1 z6W+|_oq|v>n{`=L#o*om(RJi+lMiD9GgA^@)S3~cspFx7c78hDy1UD;g8scdUnEM^ zDj4}N-75}^7o^SmY))C7d||k>DO(yB%kHsj?2`ENMwkmnaGf$ zs&fntBp_JZ3X>|F?>Wl~Wb2N5Bp_6xA)sB=Mx#*Dt=1F#{z9amk?Hsz7%R#AajVmP zW2hqe1S&-qSdURdVQwF1I2=}2*hVHef=2D+smMS%h(J?pd~z~!vGQQk<{cDngshg) z=dpr%!LLgl@J_8HiJH4D&p8TtGu9d(KT5eqS89Nb5Q3$l%T+N7mytR*!V-Qu?d>f= zcr(+a6Jcr><@BR5=J^^&m>A7>cl)gjn_x&9Gk!XwXc!vBSb^Yxs%{nfb{_h>9x7-e`#@^^kRvwC)TNqxk=bGxtG@Td4aIceb^N7?8) zrqqK>y@jNdlb`nsMiaLXE4-LfZx(>A;&U=WbVb!H_>wis{{WOSgzEO6Tfepjk~p!9 zn;Jz)$xvLXLD*2TEmn9?ng(l0!nOWDfk}!@J@>N%rH-Cv>?d1(C@gMUZ3JRf%Mo*% zzc+}A?1>O-Yh+Jv6uz1@S;{k!%axqi`|f{}B0r3tS2s6*oKgBdt4wETUa!RZJnTdS zBp|F#`8@p|$@G0Ysiu5CRH#+^@By-1!3VTt5>ZkDVfS#XO!0-(~X0)3cJ2&^u^1u=jUzH(p zyly~EjOALB8?3$SFPYMLY%|eWaCK6cT%FyZC%vYkA`mM91Sm*4JEM+`j>=KtNsD5a zZs$t@vf#qPLfip{M@3#$*ucQJupmK?Eg5YMEqPg`0W&3ff-y*w?$NM>SGb$#ugE9Q z_PQ24KMl#2nc2PvU9yZMyo~fTX&Zfn*=p^g!&^Hm^SCg&JdJWL-s#xzQAcq$u}Ci! zm66H%dRbBBlLdRTZI847DG7sC8@?j2Q1h=}NO4&5fyhS-hV)orBL~Jo8lXMmshROV zf&_p!GS}E|CLVjiu_3Yh;QW~}UKPxpGw?Ew##f_@o}-@NhYK)v{d@w5t*S!_Q6GK3 zG1$AhL1qr*fT?}1Y+mrkIhwMHGUFC%Ty-84(-R)R9FI>;Nm1eDw>9}kh2SlPo`DjP zu`VAuB_vQI>2L+w^{KXG6(>eQ%>Jk?H;Fos&CCWM&NvYDyNRGG^c5|TQYVjo)HRe} ztTpc4f>833pwC&mvXWr-mn~9fC?>251_*!qfk}cQ z6&HXC8t_vRHDpIrrvlf-{f@#NLAxJO6kaqcTo?V^lRYgTpJS1~^0PN=WS zD8N9|14T~|S=Le%0dTo_8jPduTfy>`f$lSbRaJIF@qPpZqSJ?(09p!rF^e<02QDft zl>sHtV+p+rXHFW7Kx6H(X8#=N~j2LV+G#Tn_kP|8yyvL8QU7jeUj zo(t(#2|2X=-O6&Ora+y0xkX#3T9p>gCir|-t$WD|arqOolYqiHKD9GGTKLQKPJXRr zCg|r+o3LWjFgltDxPUO(UB!Wdx_!bvh@$`MzHHCI?(@@+vZ^u|Yht1C-Hg&n(&|f& zwmgmB5T&V}mp^z%nqXj64&b##0PVc1x{fhd02vZ623>aOpRL=*>4#2FSLVlDUVqb# zkE_ve2v8ROgWe;=@9+LKFjmB!K}0Dh?}@M*1mo1Yz9p%9*+QL+l5XaLcJ}qFmcBN0 z_h#%)0+>MkvSoQ$s*twd%if*>A1~DmAfx|l;R%EdlkHpT_>S5o?1?9w9fK{oYi8Es zbcX{hQhC};@wP0o-@oIN5>piXj9|8MK_CDFU-j)x1Q+ZDJ~0hiyv&Yzxns(DX1AihamxHp!gFGxZ%wKv%QKB z<0s~zKB={i`S6goWX8JXx4CJ>F-bu@pibZ*ZPc{5ppToC)q{)Qrj%5N-OR%UOI_j~ zw&|eXclJKlmqg-e`CHx`2|&xhOao(5>un16fu=WD&batFkG_pjee z_|?Z_ySS~J>qqBg3;l?QqQ`AmMmc=ADs|8uYmvWATFLQl?Njk*dlHI4WS$%qawI8sQw9Z$i45!rU zUajq_i@M({P`YZG z-|pe6U%5TUNit#Ia2F>umy}4eC(Z)If0{1qHX1#x!R=!!d@~|t$2kbp@Zduz(p|&&>^E-TMMo(0zxf6Hs3Y z`gDcw%pk$|(}7v|{>=TrrHLm}h#`}-^h8imp90k`qF)3Me&TC9yf_Qgul5_}!%UVQ z1XmvwX)#{)hFrl9Q6C0V^R8j^?*26bj#*nPpFi_JsHA*GA92$``Ib68{w+CH9&JjP27r zPh;7Z8LI&iB0RdSo^12;3q6)B2##a{Z*=l7(B@7;(J?WSr88h{d+4sWYt-M96K3}_nVCyNWwy{~ir=bJfZvdz z-R+E-|LaB!e&s@39ByC3*1$EsZJt*7%<}SYf_3|(q(!b`LnYL6A;x)S!|vIj-w`A! zds6m`teII^c{{VpBjzaQ*ZXr(9z(6`_I}uFH9}Zn#L@D{UCWapXKu$QM**WrD7Kyi zDg&li;UI_B1Eq2;Gn3wM0BZ#6>oY5l*+7{B$OLQ*mk54xQk-;t0brvapPG~|*3QqX z!;$UTf^dwD{N5i5K{Tl^k^*UNA##XEDo#(0r)V)VGM8>|1HCe4heut(eX~wDO8Zqm zfBFO7wm%CTg{M`bPp4a@mx2c$=UQNT^J3TSzLpHiNpLqf=P#C;c-r8JiFL*?CEM9s zM*Pwz$3+QB%g6xbs6+aPR)N76wN-jTctt-hHC18PtX~o2VY&rSs(m}iL*WB7rlPS{ zf_?U`t`JkFqz+W}mcIx2Ggj7K9!N3Ph!LnNX@>dc>Jp77jUr98zIQtB&&S@Io`1uA z@%|RSJMI2MY=Y{URG?UXo_wb4YWi_6U|-Yv^|5jWLX_RSf`zW|FdB}Wq@$(r$(1~D z6pyGL6Gs6CC0JV5L5N9&B^DC_=J4Vqzb65+7@v-lRCU~D0!9#vYp7#GE+Zo+&x6L_ zOaa5pDlgUvH3Ik=<`w2rnw~6vqbZ~#FEn#2(&PDP+>I8B;nN4W>4kKF^*s` zU8wtvjB6u0uQB20(#MzB{V`nGIVrbCqU2r^cr0?@xCQr_I2-RO$_9xb3b37#gY(tp(ZgqiPG`0<$&_!a_lQFezGYmwik zg323wRCIJ+RaFQG%mf7QkdW#~Q{ngRy3B@rFaVULW>%26>qupfhCyXY-v8dt19?T?@pw1nwtbkKa) zi4OTiLaQ$QUjZtwEdh_-EHZ?ZqdS0LP3`pc^3~i`hAr3&dgW%O9oWedgg&b+p{Ou* z=Mim7`W0K4#~U)s==@+&{{5cFS!(f@dP-YTxD&dXZIM%i2R=TGHd%kn)82J%>4CBR z>lp(3L;`xC5dLzy-hd;GO{Zh<`f-~ayj<{f86T^dSmsd3Gnc4MY2GGg?i6aiBwRXp z6gW=(XafCC3i-ld$)Kz7kLDID)2r+720qX(!nhq9@P~g$#)V6J84?&P^+Pt!)zC0k zxdfLmF~|rA2{_N|Xlca~;a{O)rt-9`^Ja*tu_E%o);*3eVPP_sY7`(-CYpMpq2bfG zQ46F{+OVMlMn=YvQB%2;Y9!(897h=H{pIUzC!zdlD-%T}3+B%#Y@XRwjn%;q<_U5I zCdyp;d2`2QzdBGNr8uJKS=h5BRBRW%O3G`CcL_QXHE7O3$V~dSdk&WvA7se}h)zyU ziie)$_skElVFR^jiX?ktqsGZrex9*wf7DaC=zLbkOU9F7#4>dlQM0zz*4}K%TG2Nc z)z*N5#s-SY9wU++A{d%4)@@udi?y`{Hst}qPFbaK_|p)A?%YpKAm(kO#sE-0?_GJssnTPiKBvpq z7G`AN-=24G6pY`pYR&U+?L0O>(f)FG$t%l)5;u<1H>lCH{w^eIN(>(;6;y}~=dGeK zqQ%Q46j46vINttui-_ExjxBIvY64jMXh@0cHGe##dv)B?UW!gFY&7}T7+UWbdmTLw zTtziDHBAFikVQC5AJo-Nt?Ce?)a!s?D8C%fW9uf1=WGzRimu9ytLJGwgUQ5*`C^7z zyehTAXK&ge`B0!p8YWmeBtPFrzrhV%(BIFIEQ~A0xY3O*Iy!n|D^jVU ztA$d!3NJp%Q#-75bhMn7o?aBt2Y4KqRX?avi_zi|PGy^Y`ox!HpsN(`00(RrBwtYg zR)wBy7w9*Snq%lLzzfW|)n|K<#c>MG(%x*s-(0$)VquIoq~#z z6c7$_?cA}Tvki&r5T0R&OAJ(wHmk9eeJWzUL)>>!c4_WW(hru$=f{173b8$nTdGcE z!`6S4$wZup{u`F;Is+}tEoUqEG9jEKGoG))rdLFx7EWoJbB~jg7|cv;(n@-4|IQwv zp8uT(&p=cO@RPwxN*`wrZcxwqWdb(N?W3Y%dg2Nc^pu!PM+K9VkxS=DA0(&M*;5q+ z`MD+$1I7IfsSi>F_;n zmZ(dt*S|0c zAH=eSoI{$vZvK9d4dA!yA{XDJ!SMM<{GtNw(SaI=BzrnmsBrjCf+C$wcuk$qU#1A?ziLZWK+W@KUy7xlT;u;)61a5nnJanF$(OtO3Vb* z-E~NG`}-pkYy%^QSmF{cME4uruzaqrygXuZHII5)iwiQTrl$FRnET0}KsXV)MqSao z%6uJ>_Liz#GozZN|H*Tjh^Y5ut2zsl0n`o+0uX+b&gx>XtfHt!UT#~P(`C11HPtc| zgAo7+;~z4#50_Ivf3lFM3+k7NJqKu5e;9GfaqFYg*zR|>pAA1pO!-Zwo+(W|JyYq7idQFBH@CapybalYkSwYH)PQ0;3P{oR;@ZFuG+tH35PUOG{3!`a2t=qActr@YN zUQ(iM|7dM@Ut9F5uDuWR%4+TIS~}0baN=!WBnOttkYiX#95(Gi_RUTH=w4K*Wb5%(v-(LE>NyQK(c`f^si*#LC2Gcl=!?`;&`!hV{Ub z3Vhv$g_dx2bDOPwyZ62NJ!LfbCHGwJw~Y2Sp`_}ea@KHdFj>9a|F+R=%b!f2dn z-_r;8oytN)N1z9Vin!2y^9Ls2CoFLsCUTw-8Erot-0FeiG9@BA?4M^@#Ey|Kf7Zs7 z>>QLMY!K;=8?FGe;>7P|Fn#{=~8f<|O*BmXa13aN)S#JS-uBz(U@xFXOiO`%QZr z-aj0y-b-sK9(Op$EqTs6-?5GI6SnWWe+1f`oG_9hn~XN}uzwbw`rP7`-kdgJK7DfZ z6isj$1_Wc%BS>4z@wJJFm?(b;)Gwq5Xl;K0IUSjl7Z+(9!Q0YiksAzK~j2SmZYp;tFJB>dDBUkBu`GV?R^b8+K=? z4(+ZjjS%g4WdUX{W?K@6%EIUY8m?dZY&U`jOgtj7ozLeILXF_wB5qxN@FXLSkyR$~sHC za`>SokX-Nqx$~yh{UcagzH-6NlyEY4xYp=Lbji^0sJQ3=D?LwiOte27S-33C>H8^E zWp#P}zb0+fN!7aPS(?tLv3Ds?ON14DeF!Cq?9H96IVDNK*oO~{%)d*4&Ew@J`+mff zdzY~LfsWsPC7}Jh4bg1Z?67@aj-%s&jG)u#=^@a4%K>dSwe~HErkBd~{$HNYvQw7u z8@~996{ckg{Wek4++Q|#6PsE8FFD^yzP;Y5C)rQ*0M-w3VF##oiXQl42#E6eUJG09(# zQFWu_Jq%SI{-JJKoUtt)wsUliWYr+owB(_Edw*5uf zP+LV9(28knY9NbG_V2spSCmk6zpvL?&zxBN(H-jZ7-_u4;rl)En{8L$ z-74bI+cTxIT2!p#l-MiU=MFD~O#ZJ5AUw;9&U*}CTQ02o71jB+YOLw&D?$Hdh--Q^ z)I$)!>lCcrJP9ldp?}<;_YU3=0vuF~j`1s9z#A0MbmDsY=)h4~!#=!x^W`Z0pli(M z?Kg|vSksx-YD>Zt2dl}q&O3%C(|r=PV(tG%Cm>BQEjQDWHZTwf7s$tD=#sUmKn8#u z;3*9KbL)ZyTVve)``O!Pw)F67qkF^hGdcM$-%au3vkM@L5($H1Whx}}X_QYw<;TW5 zVG=W+MadT~O&bysptnYi{5?YbgUY_VygVllUo@#s?X-LMJNIO^kbn3nvP)yLnY|@~ zy6*`*V-a}BBPIh77MMJujEiJ1HD8!%8_Y>|`|b)L1X`_oOjx?M4(1o zZS}|2H@`m-iZ(qnJrIcP-LdYd`@Z2mpEkhqiiq`-P9U^-y}|fA?4|;TBLy>bIT+vH z;?{@^6bg>pHO2Ls_uTHmNyihVqtT}Ms$34aRX-p(_z2+_Laxd$OzV_PxE;V)vyxpgB*20YmQN?jpZ+iL@Kc9e_KVS3ua5oGzbfIKlK|d`a-YCY*613zuNMML7zIdWqeJIg*1IN zXIxm@9D3(|&sDChwE5c05~l)EO)P-L1m4z+!%~T7WbxPg4cRYW2=D*kPMNN8Ndu29 zO}Va`y4cN!gz~*5pJ6d`-eT%O$lo8&o(`)|cX*Kl9$33@(p+nkdR`p|uz)|Qpzsx8 zH$fCVHY%^R8r~)uLAw&4BswM}p(Op9L{b z&)+M|60x9j}T4X9}Hg1@b*?=+6Sm6aq1QVJBPl0CV3T*o~}nx&5S zu$ZQU*Yhn-kD@5oH-LyviVV@#6M?kxo_4ecB~nF+6>s*N&nrodQER|K&O85k>%Ek! zdNkL4pD$=M$)9v@y(spI%CO$|t5BDoMMYHNe#3(a5yVa>;y zO>CTCjJ+TEYz>>DB9v98H|GNH#erBzi5DUY&%KXcmy;4eJK>^LeYn#}3MVzg*qE0MkC{p2?*;(Nc*pj3Wh%Ma>G*2)3 z;(k%fg``^(|N6fA842N4HD61L5hwp{1^@T>sAaeZ&=w#6v}TWRFw1ch$h)g!0>7Jtgmij&Wn7@y-I2Q-Z}aW^ zemf)EB;F$|TjEDfgMRhWuSYi^A6ycIEuCvBuk!mFALe#O zQDnq3HR*-F*nyEH3sp&-EX+57mmiYnln|IY?>bS-$jO$m_(TrlBVlkY?D)3!-OKDC z(azo{!S-8w@1C-lL4zgv_pAoh(m735>`-vNNmN+7!#1d|m#X{h9}q;2cNK}M28|u4 zT4C911qJw@W5|UEj#F#%L=CW4-!Cv32{3QmH#!3cOp#6N;ffE@UN+y~=z%%6KI>GK zn<%OXeAE;8C0{X&Jx6FF0WB%TMq1lVyy{5r(5BtL%U^15DL%rj-^(Ia@0w> zJ!1iBHhJwAE#$*$S-Z)~_;d-5rd|nhW?afcIIKk1U;w1r0rbzN*rGLhR(F+pUF|cq8 z%8H0+;?Y>sA)*PORNJ&MK9%7Za-ghoYOCi>ol8h4Bs*4OFkyz%!eX_Drw{V}p{T@Q z`Q=3vc)x)V_WrYUg*Lv#*df!k4rdZFB%1o5#$Ria-`p{eela5jgHL^q_58T9gnlI8 z6A<*;Dfe%PUBu}Et)#;_Tezd3VWcght5S8!LcdLQGc(*4&uU~~I5gS(zWvNAEF6?| z>eba)m^`MY9%}1@5HCyCI6p>}pj`K!C|1=}k@0$dC$ZgnAu3az!uzx+vC*gQ`y`=M zFH0|YyDaW@;f-1E?{D;bbiyp(FIJP0Z|gfkx*m<+|NUdYUL{UNGY0e|MmLN>r^tv zn^)Dv_uK zVMZanvo_TdNFKX?27Lb-y87ehIPk>asM9Nk+@6rTlaEQcD*N-vb!k7h^<1Qx3qHrC`%BuX`M^!q-K8FpPLI$qp5;c>OCr@r%tM9H_)wZSB2d?T-I__qm(E^5w<*)4}C z6Z3%>MMjw1snDn@mj`bZBm`Pk5s^fItVEFVgNz`StA?oGdyfaiW97X4y}=3__&=J? zDJ-+M3*%EwwlUfEWK4Lo@n+lRgvqYSuF0;cCfl}cP1fZ4_W$6!u1-3tUGILLweEGV z-@UKrXyiQ`(74FV$?WjoW!KbdKXb^`R4>ISZrGnNLRq$;?TMqSG<+aOuRJ^S{XHOz z#4DO=*Xb`w^MKIDE3IJXsnem>6)8>xAnTp7vI!v#l|ns%KxpvLmYthO7-)%O2e#Jh zJ%V+&nU5=q`hlW!AagLY%$STOV@l!TB9}Q=hSU2iK0PDz;26tCu&>wMr?Lib+f`TG z$lLmaf<4v1lHApp-h9aSzYR9)Bp+860NFo_EvK)zDiav$DR^w&e?o)~!is#py6hlf zE0Yohs@z{KEzk`MZfP0r+mrmnh7RvL+^)ydegaq13IIuVZu#L_l9q)9B&`L{@9~$A z$)JT07UfLROdUEgoN9i}B}(}2$>_qO%QL1*yEatF^TAG&(VS00q!;Ay1E-J3k`o0Zsyv6dFm06@BFror&H-qpK{Kr}Slrtd6 zX?m&SlTFqy`G3Hut7`zQE()sP9ji;*(Mgn8Zo#i+EJ>jcLW{{9v;SrVDpvDnFI!JS zl^*@H!m7H3vK zR3V^al14E)4fb?7nPl~39w$Uf}^vDd5VRxqkpqBlnI*u(JOstDS8@HTj zeEd*__&w2*&9FbN8OqC@_M=#~({m-&?U*8EWv5qWLlILme$Ck^{mhMkQTUJ z>e}Vs`an6*ItvH&89zj|DJ!XoKdj$=!Z9zoJ~a?PnE24fy?XII*4%{n*oEY>-|RU$ zq}I~XLY{3waj7&cu^ zyMIpF`?TTc)8thPEb*Yh`yGF6d41wy<;LgBUL@NTAA$EUgI|Bz3Efi>yPg(mTpre4 zNGd96OAZ&^M@?rv9}!XW>s_EOUS557m*Xjxoo>$y)veJK#hqGS2vZsvgEkE@$UYc5 zJ~sLDOH0FN%K#V|!u0Yy8~Cg_G_|3!H_o*pH*B zowW8FEm7Xg50M9Bt7Tz#Ms>5BPwlL6;LS8Z{F zE!NJ5?8~2hRzpW5-r<+cJ1`l9L?x3AHh>>756rNmcNb;wdeOAYE&PUmfA=t}==aQl zbcbrVmSWfYcf8~R(PG-|5iPu|-^AZ1%TobIr)Ev2B$i|-m-qv-dZkX@cB?(ze#?#9 zc?E0J>r}7BWy9Kh{`BNU_)YQkc7*2YHdi>3d#k`m5v; zgP~S)gEkO@13ObFvtKhCjV((h+dnSa?#Am`cHbVhKshV)NYsMvwwrSQd~oespUkoN z&dGB|RF-C#)ijn~zju4v69bZ~Wo3mJBaRq(dd~#+dH3>u;m-(FPqye-bp{{H%9zt_YJo=&8ECWjBDD_(Xp!ofRgP0 z!Y^zLi+YARA%9=E@YkttqDl0}-hM6fy7No6=SOyvxT*=oAujGP$6mn3MgJkwlF`)A z<+lOOQin=OD=(&m5j;-%uk+SSQYt&=O9np*l?**C@u(At`R!Ykld#lh;wfMaSuK@y zHNyen+UI&K4z6T?KF9wA#3d{oHyWwU#?BXf6DbopxOaTn!MM7r0UwgU1})v}c}=Lj zy4p`e(y2%dpyz6EeP8I2{__%YKoPRQHMkZZ{b(Y7U&klG@9}j%M>yHy*Du+QC7It7(6Mz&HREdXW!V zx%CITRtjmy@RLjf0ZN$I@tqe|98BKK$@Oc!BJjH@*>3nT9pE!*-Z8-yiAqb0fO;8KB#=6x>&{N8hg zhg22ck)f%~cz%Cio;_LCc%EGg$B&o}On0l7E@%sUoXssaoa!@LS@x*sM}_?r(U-_vk8hn&5NNoz|V_ zkUdY&n?hzx^VImLq<-vP^Z4Cn8tfQMbuwa%@DTC-QH+j``3Ww-ZrZ3#L?Pn&v2e;q z7LZp{g9{3QzJe9Aw9x^U_k_+insDPSKfAZdx_Wrb)%d@Cnb}$rJDT(mD^c$=Zy?W+ z>ou?4yICN{!fowoiH<0UZ4nK2|#=#w1q%)fSbjiaNHjGUdd#S74_%WcqPsxk&8C6vMX zp)9^nYs(!k29{R#*d_%*cT@hIKQ7;{UVd`m4`-RI)E#*KEu%q$3-vt8xUVez18466 zF(~m|>%IXlRK>S!AlP>%jMo$&ms3=kO18&ClJGM1{6IVYd1i!Z%9zm1+o8%(EIOcRQNNY@sl(T-=EmWKxOg z(`Z^|hQZps5&*ODYvC_?RESZnCICo)%oz)yOZ%;JE|56!JE2!}KY06d_g?x$3<|Pr znX9M~@RrEhsM_C;P7*hOAI5$;I9-l5WX?U3eEZOYm>wc&m*%%$eex#|<#RhT?3wSs z%cj`t+v@UenZlP1PVX^HYC3OD$rW^0X*FL1UpTL%tK^B(66VF{iLkM<4g(hUic*8s zNLTvUkS7qUG#P+3kjB_cR#!J|tE^wfIT1=B$ajz+LC~aL~zm zy6SYf8Xn=71K58TCT&|jut9>U1Hdxg_@Th8r5kJJ%}*JcnYFd30T7D@!`HLtQx8Xz zoDjfEp!oVXwW~Lk$DtePyW+VBJ!3Tm(bJG~~0ryzMZDvVk@^CO(;#ZI}T zl%(Abk1a}L`uT;Fz6(Y`79CPn(~Zu^An-e$Qyj5mPn=1j2@C)MrVk9Z+?0<8?>u5(`w`KaqRlNQhBV|5=tcKjpx>IfA!P> z*osOkYW6N(jVbrgxj6YCKX&tJS(qfjllc!asCxcmb~ovBLq)M>L_D+i_<)5c{*t*z zlB6TZku}(rno2``Njf|G%eGz`h!O(`$V7F?!R{0DJr?rM(W!E0Peq!q1J1pUyWOtoxw$9@pIe>d7H|9);U_#m(%GjPFgrWm39_^@po@%L z2f8z8^@RmeTF0VG@-FiYVCI~t7{!0FI#Owlm~g@vITo3>lnxOA50kG(PVdZ%Z!wjf z=XSaj)dwqFWHzQDV!=g06o;5B8sNzu8d1xJcLy_zkF;*u$f~MOd{`_fMM+Mc5*{y6 za2Nn0(P^~qNXsD1I~h^loSzRBh$4E}^5AyiC0ce{VlHhUFs)<%cm5&Xd35o;Xt!5| zISrDR7t{|OOiB(DEZUQ$V3FiXfP82{eNrI!ecO>o+(Z^puf-XDAS6V$V1_+tlXiCm z3mdCBWW==iDTH^wEeZ`jY+szR|NT9#Q`U=;;6jKV-oO=CCYD7Pp!7Dah$S|-s%EKnK&uuY2V*%jas5R(FE1Y719Q}URs ztbj$s=ZDmm>H=eH?~F#fHkA7qSrF%LiH`+@u*Z%-Yinz1VIkU#6xJ9YAF%rntSGOb zYNATv`|`yANTT@Q0rvk|WBo$gE`8w;JE3zTp|x1I!)9zTKE;bv47~P2{w2`axu3iN zST8fPMfbGKW%-0tWav?S@dl)!Vc$p0-LCpzQRe^-Pw>#kl@2{UUErA34?s@Yz4(41 zDPo0Ba@H0o#Uq-1TGR90p+ofL;R17M%dueDug@-;GJLaQL$n8rF((`@Mkd6SOBMi8 z@O9wGB!m6lVl*^e>?mg0qZP(xJ62E)RX_4S!0BqUdo zD^f`-^)q^;W_A zu*Jghq!;@2kw&;L(fk($$o8y;?2wj=ub3eeldH!-k@_vVV^7@o` zb9Kej_R={8fBDMzGM4+!*l5s|4Mc7`J|3gnZq%hS%oET%9#UkFY2R;S zu!rJEaBp2)?z*8+cUJs`s1PC}t=8PQjh&r;a&uSx($M#VC@>NG1W#$=B=`N0^+>P# z-}zDmqy_~4qFWM>-jtY}oD#X2Lmb>|^t}CsI^f}V2W4$UZzy%|+(AbWU)MR+NVRro4q>ZqIF-L4ZtA=s2(c z+d-;SQPS_*ix(?*cirK1LM3;2X-zHI``pbCuRD)bT|N0;$$9-u3{LC0R76fYf>O^TK(2_F4biF(3746MuL@B&8na5DZJ0HU5N9 z>wIqwNrCiF?*JQHc6t4ba<+xhI+Zj3<;*He&>zP;TluX0$UfXQHi^>6(G(4rC2J_c zWC&!W#l~x=0HnA^=!UAw+C=@i@`b z-VSJPwkQDmj&q*7o8&4&wPeb0;0A@y=@~1ffkl#&U|GHcJJ-o>^36t2;BO=Lw51`+X=mYL~b#WW>(Y0+^vORr@; zrqQgz@o*l!QomkV3V}-ScHh8gswpbdVHFgH;saUZLCnO&l%H=6-|;e8q1jN5DN&&1 zcd_DM%Odbfbu4dAc+Mxqkm9x-a+K3IKF5Yg%L$pLCVh@;A=3Mr^$D#fzU?mM5}cibLqvP>(@$E$=A5ld9Q2^0(&m(?g2W`hO zjb(g7W?R?N&AZinjBDK6e4r=jdq~gEAufkadTJU;xWFh+c4^tv#@)LS)!mpTgTBdZ zkEn?yTeGgG`vM;Keac7~#DP%x%;QEK`#%25PKf#y>fqu0mWG)2TOl9!XqR00>dR&mTWwI;{6It~TB&exh)M1nwJL zLIhq#MFmwD2T2$z5gHJw#N7GOcR77G|Htzv+vE6U>jPpkmlr;JB>0ywEgs#~>DEUq zieFtAY=Ph67Wa79)fH~KOjY;oR0mwsSV-%$wxn#*j1#oa- zg3>Wgcy6M-bn_CHlgBh>R@b8eCIey!WrRiU_t^L6hpxFY%%OoCL6Rtvw=3B3Q@#Gj z<+Nqj37q!27q34oI?_)e_T$lUk=(+sXy)1Id{r18XX_xLT-16*7d(>e?v}X{(0u}d zGcOTZAnBMcPd18nn-S6bO$p5vkc*N2cpc|2+VxVibu%&oAB_^nI(7D^`DGJUsz?Q( z|2-Euw%VCC@jMnY>8zaAbVYh{_IBNGkkq-9DBgH7%H$HiMi2I+^y@=dmQ&l8~{3-gLm5a^A3{Jaz+C*4AYn916AC>Qp>A%yc+#=(ufd zHlH(JoL&z8&+P$NQiP&oqPIz=1cHhOVf4ZJy`L?B4OtY#D1ES=e&`A`3uFK(rwo<* zhD(!FVZtjQ0PnlDlue%(C)CO0$P6u;boocKJhv}59%vsr@?JlmI&_Hfyj8cW=l+V%YPeecpYPF`VR zxZz%wGJOp13}0Mck4ZZ-vwzK@E5zXSW{H-bu3L8MlDch<%H4L0?9LnN?J8_b)Jz@O zl>jP!8xlh~ukDj62Zv~x+I0VN8Z9kz_iQm3D1dD^xrFHat_n!P&UdgmI)Ye3 zSJSjGQHYZg;SZ{dj#HFQ?*;m1-6uwjh+n0Ja+Jnwggl;~C{5Y;gd$p7Vb*JJ6(R@r zTo2W7|DK)#p%F%}dn7E|WhL*xAmN42 z&K_;DTca+wsVGz(QW!mv%K!!$&`^GV8XJ{(2>)Zv^gr?db0;nI>#z~iucTP9Q9Aam zDc>-*x;}oFR@0j?fU79m`eJ_xlZw8B@S>?l>v{XXUH)LrL}tW8phzB#1l}(tJGbO! z*C%o&EtRRD9#U+aWF?OWEoBW=*r-0yJ&HX;BmulwwvS!%$(*)N9-TxXA)xRQRn3k{ zu}8=#EClgjAh_Q54I{XKO|yzC!3&2V8GN?&0e_Dg8~HcV*?ZT)KTO*KE$;nJa@l`d z)mm#cx_HnVSnS&YUa6{Z3spfCyV@rn-pC!BI4Q!xZF5qYJGThTFhgb-xhUFQr7GNBP=>~V#Gh6cGygA z2auT=RnFs>0qC@jOMA0ZS8g&oaqMs(GjWa)BZ>k)&e+))2HXbC!J(SpS{E%dR$<7Q zo~J@LkoOk$yVwQ0$l1zLjQ|S==LX2_P-Voxlxav1fqTU9%`d5mMX618?HdFiUt(vkHGZGBcItY|@J5Cl4{NR)Rl3Zdm$(QBl|1LCyT)W~?n=h#{xU1Ta|Jaa93G(wn|7!P|k2_M7C3diC&t85kx8895U0 z@p!Tam{nC3s#&pJ-siWrVgQ-l11ED9KzI@~L52=c+PhRX)!-_GAp*&JCW)A==}5b+ zRhd>sJq$n)vy6<)F;K4jUON{p1J3}-^wYK0$eh;DkV3*3Cs@`1s8~mb;a@dcWOGe!rY4|H|O>ybte*3#mj$mBwfeEZk|@ zx{Fg7A)z3_H-P1-gV6c1!AMAPq7}*!s~H4={dAeb`Gw6qxKT7%969)o5QX*Wwq90G zXS2njTW*!BSMwh;_5P1NnXY>upzv#9h~p0}c1p8)t#zLl!H+hIaS46cyzE+LTwsOHuW=Tg18)Q&!0^Aw(e-uKpze{6Y#*Q6kNVx4_BdLkcK( zrRBwtfVovp0u$y1X4|!G`_?;zV{631{0MlmcZ9hUCeOoM|ADJ*+E_xN=&-KGpTmcB zN>=x~%QMUXSZwu=6V%_7`RU;+o>rEeBOnx$;f|UEcrUaVz*D4;hL4UqOGT66C@OJc1ZY=b0C8xV zowoSY)YJe9RKi&9FsDU}i<1XNy65MJDX_ratY1>Jr2joLQZ}+>?c7kI4RJ&bgQ9?< zw7MLlqr+FUWFc|Iv=I7|9N?JScO00bQM}%d&^+~_3JnMK{4}09JwTTI5&;1REo#bo zj2=7KtWbfT74E7{6bcJFGrK8qZ5do#BDj8fFZ$#6eTMwm0ItEV3!;nd(7)4=IjaIk z&QM#fKB#w6rn0i$ERR^@0M@Qk7r@_?EW4k63;0Y?yo{Z*03~;Nd6_(nf{f3rQugNQ z6_81hh671>oWslM>DjorWGGDbEFBzh0i950Ot!53PnJQav-0sBwbkIB$mK$X=l0GN z#a2!nkn~2HIuW_@q}8m)420$O05y-v+@O53_Y-}ZsHlq#2l)7QjD0L@oNr1140HKrT=aoX*HE)s0_2j($W#`bg?F6h$C!tRA$|INbhFf zlH`_&Ilt+Mexvnemr8L zzUPMQ8-Q|GQC$YI(P03N$lv_BcO4G?U#Jtc)}4;31X9t@Sp1(j1aB5tjOL0@S=ra4 zN^-qWl8cJg(=7VO_py90!-V_vJT@akizQl*a=!PkUABuUgk?`LjYlRkfNPI(eZjV? z&FKsC3%e6b24NCKB1g{+yLTsZSr7@}iayykgPso5b;J&tC?L_#*vf_f;}+AuSe-#5 zvg_kqy1cBqcThg_dh`OB z6LO5Pt&g0~2aPbcB+3PFJ69JZ(x8~w+N$X`*})#HoZ}G@qiL`_ga`DHDJ!cE0CS!B zJ5F13h67x_7OE+Uxj9wSrnMgEtId~7GUAAWqAGm)WEIPK?X;U{xM#@SLmhjg62)8z zR;>OL)gvWktQ+jA@gG3Aa;We5mN?OtD*&q-3QJQO0-f@Lc&e^>C#0iiQp2E zfVB`sRN}#}nOqTy50@oCmbbB~sW_v~ro@gTmPr9cc>nIQ*US0#&x73eqlDyhHiS|D)W=^kp;lFK3>+WxHh9Y$L-4ZEiEm7wKj z<8iR5YiufuClK|)M^w8iBZW~bP?ItCWCUMsYW!DmV}^#hx(anRdPBp4nx0-*+=I_I zi>a)>vt4rgwgU+)?9_1HxUiH)&fAuB-o~TExB>OckZ| zEhXEr1<`Q+lyxtCaH;Fv4GR~im!qqDuIzJ6T{ByCm;T`n{+E)#^S>vSmzOK)>5Q~Zc_g-#e)70zNBr=7zEsoHB|k&?GNOgjHbMU) z25}WVZZ0^76)h1TP6K?QBZN84A+J3>?thjoRv9j47pi;geEaj<$rv5^y*O+d`D@L)0Wm+9G6gsv@FQMl;X zSf?s5#o!<{?G3!{AKHFR@D5!1=*ASeUZ43UQkSho) z5y(Bipvs9;^a|PJvp=oe==%aDqf0*94w$;?7JG=w>me(|aQcMCtRaLM7*%gErOci! z_ELvah#@_*w5GVQkersCE5?;9B9J5c{rgy#P3BQ+-onW@x$wnBclP7^N5d&exMvE- zR$W>ahW!Z5gp~QE*%8bVpdy=IU5y-_HIA1u50Mx(+6xM5*&e18Ws!E0oK);SAk736eT8ua&BX@`|+?50ScS*w@ydYA%BEYS6txGLW9TkFeh-8tUqDPo0wGj5(G{2c-%6;mKF9#%5cdC9JW%cxe%;v8qpAerF& z`#Di|)Q5-uTfu{V!590MQ-8?m51m$@onSs3SsrtNxusCC+T=V($Cx=Q7gp+8faSd~ zz5P>ddvXbHf|_=L;1LIRW_30CBm!T2kBzg5^9p~8(1q`8y3|#)Ol4*O@d(huZlyTK zG0J$R%uKm1^#C;bH!(3NHS4dr=@~}gOm)|4tqDoVp~7ktJxg0aQU6ubgxlaItF$%! z1 z_~D^IolHS*IX1)cpP|gmTTd*vn6EyY_1(Om-CY0d%qgPM38->BzKT7JnV9UUaHXao zt`FF*axh2+_UM|pB&w7w)B!@!aD=Bg@X~UdnA#$d_*@0(s;e?^5+sY}89KHhvI>3; zW1QN@D*Q2a0<`I<76hqWGBmM7iJaO@Da>}EbLL0Dk=4H|JxDnkEJj9$6C>NB1lJ&I zZDnMSvSawUMHdEpLA^y6Ov|j-8E(lLA)m?j6nSv|x@{quE=p#!FdBPiA=#RQZ5Be= z{JDTiv0qf=X@8{t&-rQDVZ5 zX%Yr*pr*p4Xw}TmxpdU7DHcG<-HFX^+PPVfCC}VRN=>C1q^1T55U4ZS7*Myiu>hqp zHfe;$e3GiF9uo|VByGAJ!#B}M^Ez^Z2o-YlJ^C0JzzsX9#y2Jj8}{36ZIKbyVz0pD z?6u{#g$g+ap3vkPB@hW7p=j%AXC9$cdb>M=wrlfw|59J%(?wRW)Cd-H`db~cw`KSnMsE#FT6 zBj-#ll9kr%KGtxzxaQzIX$IEOfdU-Ob3<`a|NfczxVnmu*7N_o{1cI0W+hWJH~mH4 zk?*|SNIE-9;h-=|qt&YC^q{`pMa4=Q{bs|S@i}nV)J1nUYekQlQ0S44d#$q4Pnzj- zq9I$RY1;xdV1+0wZuKU}CQL>p5+L7jTT+lym>{JX>$PN!x9}zad`Ufb^Mz@dIzYJ+ zI6{YtMZfzF&BDjOBwD0Av%l;)XNokEKw^CL&SvKUgEDQ zQ?%>YmTm04D%Sb!6SIX75RwE~Py?G+g{8V4p9+Q#RsOSoYIIobQIFt8hmYa5N!QGg zXUmxkl%w!u{~NFW@DW+)ti+co(k9NtvlvTpyXy79^@4(Ebv~)flAVkH zSP8Xhl`2+R)R|-s|w?s+BTgW@+T73<$kx z(bSJ`BC{v+KS3yodmMlbNhDw{^a&(fFML~F_Vfkv3gRTVT~Ea{-g5{}+= zUcB~Gs(<;mrkxQ>PCDC$@AvQHmxoSSfk_vwo*N=?sT%<=R0bj*gX&*9X86R+2zRJ@X4!CIrR114c5A_(;1-0=TgTqAgHR z!ib!$)`%Urf%G+Xh~mll<>!q~;e^}DkyEQA+NN&@T-jTdZLofH)T95Jp; z90X}9i-Rm+ToaNde_MA$;QoAuzqqv&cfMqo7`1+}9wM8X1QQfLF)?WxZJs!4IBl}X z0b8X&vu`VbcSpUGo}H3JA5*C$h174zjxSD~UF^v?hzLwBkt@ z_R?{yA#q8F@v!Q1yehRDvTXj)19?6XSTfpaw+I~~vLmM2n&Z@(wu`#tIAIg^MVtOj#@6OCd1|!vLv53^+H0A=z4l<9d_1_vVSF#F z{aNn#68|{uc>_T@LB07tNcX5gbl3?azovL~Q4YqVVF6oyuimyOS7sjXluvcN3%1Mh zIWPjqffp%MDc^SHy}G$bwY<-0&@EDn%*zw;J7254AW`9x^-3Fw&v4gOw{J^)1Y3D|d=iv&^y@%*wDI*<2P=;EK0&ilB(mpm?UwjLLk2C^(t!pKGF8EMA{FyQI1kYxAv z62}jm568XCRb(*Cj}E8TXR~H3@5e87)CxIxy}Yfp)pfTg6%^!tKm9PMJ=RN#2^Em& zKj|$RTW%?esgS3H*tCXk)DN37&*jt9VKQO$>aQF8K~nZIZ)8TEK6}h$Zsyoy z5lyv+Qbfsw_*o2GvsGenCAYLfh(d*~Wrm;f`uf`6Qg0E7h_Ip6wt*irgz3m*7sxiU zeLW5z15QSM44j;~hL4%C9A+cqp{cH{uAX?p@wN4|rPbPYJgq`OOo-ro@WPd{n3iey z+VB}r3ycGG#=Y>-=&J+N|azs+Ugtfq=g`s|Lp8 zNm^58;2Wvc!X;USCfTwDOHUqxkU4vkeyrn5q91Lv1}H2r(5iIoQTi+)qDCX}mud&> zT5{2fWL^C+tC1P=^N*#bjjKNuibf8d=~q*j2NrS|uDp+)7V5bULqC1OB$gHzQOglY zN=fFh$>CL2(NRz=-W$FZQ5EqD_KPN)V%gc|%igF*URU#xy#3<2z-pWU%j>7-?RrWE^ zB6KC2nU;!Lv=$CNy|0v;A|qVH7=wi5i+yZ>j-#QydrDehu!@R#vUNz3^cZ7u^idMQS1UDba%m~13Af1DpG-Z!*!_H$_VoPx=pPb?p zEl{%z^Wtme1Z9l@k?<&TWO-$N(#)x2hZxs9PFjZvGAgT8C>d!P60`)9d`w*BHkL}z zkO9%)0%hG@Xo#gKr?a>y^W_em>S=`txPS`0QwtyHUl^!Z%2eK zbK-#0I)`Aj6b+n~4+muS}A|uhCkx{l8rh|exbOUGWqA21S0CjZ$Xv_nJNC2ND+gBl|$q=}q z{1u-PF*A=Ez%$fT2NH)r4Fx>6E=fdZCn%2;E6zY^{d zYPfI~K}(4stIxWr+myAGLo0`wb~MgB0sgK3v5+#nj%uI?F4%{j%rsBsZ&HsV>G>bt zxfMBWJh{*$nW_a2m{Idt0j%f)U3;2qIWBz_{XyLJC_Vt=;8;?j#U6E~W;(g|Zgp;v zw5dx=1VKZW^TtVzn%AGG@x^%3S83Omi!E1yJ3Y39fJx7HYadrl!6UsSlNrm!q|}lK zP%nK4mJ_|2!ks#yM(Fice@aN3G;p%WKRh7U z=~5RM(o?y#=~@-ZWop_DvzMqq!eG1!(#>N(4+A5uQIwy<>aPWSK=Y@hq{}0vrA$r$!sPiquYRiO z=_InAA}39lsem(?GB2GgBqYMHWJ1Wb!1_$zeWO-v`Od>Z6y>AkqvOd@8Z8nf>S-~9 zx+yN|$TmJnc&srX$_LzSfPMr~qW!yqDdB^}~rww5S2VGHU_QOw6N{gCUJntdgcJk$-D3o~*Rs zH>l)oo7aPWz^|OsCefHvb4YYBYp8(p4f~Vwl+@rH>7DuKHbJ>&-sa#^bpqZ*g*rK| zF+B8n0>N+FeL-*0m}7irD5ffOATJQr)M3JGZy*VCiD3EMm-_@2xZeJRa}Fbm=fq(`r5ARN47pbSoZo|$ok#u{f|jR=7Nyn zJQDa=W}(eTYd<81yX?MCqsz%E7{JeK!KfKd+G%bC< zz%E*`xE1oCwIz3^bJbcwwt`2q%aHrc%>9>6vrpzW!|Cc?IzG4U4hABcXn5}lF zh4!0K=|663Tx~BpTPjO6dPO3-6i#?L!K`Q!WK#%Iu)eMbqf>UmMEUoLnAMC_%#qP& z%eAUUUKi%(x!;7JXL5TC-Uo8Uy0(VulE9V$tmKaW*1fQtN%S9k5JypsZ~m5H{+ZR@ zY`f3A>kWx|4ikP=z0>f2<5rM{I9-g%Us(||wZDKAxawvuMTpRU9ZK!t4Z2us=(Ahq zF{{yQQ}{bkj2!gI-}OA>X%AQbN2-C(Ot?(i%ylCT12p2;7$uTe8lFvNNDVw74BIHK zd@eTVgsPK7$3t>0tf%)3L z35tS3UX<2N#fs)umnw!Yws*Lz{`WSq@2k?kvFlWSt~e6!$F+Ze6`wL+jUt$B054S= z!7i&uR7x13k8z4!kw8Zb&RFJmvWjMR+H^6aPATsIdrCa8P-xBO68F>1nKhpJI zMgORYSlBh#HVg^b#xyGN$!T@y4}GS9`c`;ZUpQk4@(|K;%%?ABMW}7~;l1(o?+XN2 zI|a`1``BU?BKXV4-vtIQEE0tx~}GGW5#H-+oJ? z9Zs$q+S=Ocm8>ad#1zJ%m3yC%7Nw2y#Ku(86H;Ffk8XU`z%e5-lDZKUahB|}4M_b&TQ?3(B3qx4yVa4TB zdVtB|oYOh~j%u0I+e+c@E%I6_$(Uvoj~5{6I6^{U%o1cE!wjMtm#CYa#t=;2u5*21 zN+|Y~rE4(4V>|t1w!&)Hu!{1@%8c_LT6epx?^lE=M+lDzD%HVNWu2=+Q8+_YwJAubyuY{ zXEoi>mmD&RMrw9+wPmayywWeRU=>MGHm|o0PteZB0d*?SYq1Y<@+c7>4Qi>XY3+m$ z`Oty3VMTi2z@oJ0-RM-w@Q`t+vsv!mHX;R^-&CaI#Upy~5I@P5tK?r_Ut3qr>EUu? z#8h!?=xq z?X^JG{1_D!6D+cB3CprMza`^cuYk)o8HA*M_&Mzxg^~&zC&;Q9E~GFhh(JA_4=TsfVfsO1S9kCo*DJaG5`G594hjxMJP?P zBEz8{I_5F;*#5)z@}+s-qaFo9LO<|JfsvV?BQqa=lR!1*Z#Gxjn1+xrK2Nl zGkwnv{j2>K=T8&HJP5-8-M@R^r+v3SzQQ(c+M#}(FJAfZ5@C!5ckiy+C`mlzv@;gW zgAYHRUA{Dr2zJC+=eOJA-f)TR1C~ycc z7eZKsKqpDft*vz|ri9gMHJzoIQ%YeTaJsUxg2zVN4grb)z(}Qld!Dw&aixT#o@dj3 zAA3Q7F~*bAf#aL&0s`wrRn-nmG%v|dqGUQ&xQQI){C?~1bU31D#GP!vR_Gw4i@v|1XRlSUOVvlyIZgi@3ahn!Lhgb-9okph4v2vMyR(?%Og z`G|AYYmPJx0#|lAUF9Aq7+w0Mq7k4&scDT zAWTFd8;)A+Gw%63c<|#px5A>xkyL7&0=AU0UVqT57e&?z{3zSK`vHD?=@a+Erx+lz!^3`H@7JBv%uY_!RyP17z6L-pRf?~;3WY&Ut9vsx0M zQWzVD0U4$PnU}fpeIIM($kOr>edwV-fQgAoaq9FLx@+eS7y2RGzVk7@^T`69o}O0C zk-FQkaT97b8urHZ8|?i2JUsZ&{U#3Tl`l`cl|%Ec}gm~^S)hXae2{}rR-Be zB5Q>jA6s8iM$n)?=+zpH(OSK^T4ckiO3M6$_b(4NZ{9k%f7i}bC{>M(jo+%*>SwKm zGK%64wN=%<+jf^KzCtpN|pZ=rI7{;-eenOvl?g#qU z{)cqp@6q>;{K*$i$0uL<31S|H!{2+}0RWsodjikQ9{GPKrgLwcLFXK*@q<~Ta=DE8a=-MzYhD(pFM#9AvAOF=`T&AbFUvq2cJ0VE}lP)9-sNe+|Ipz0v~wdh&}(t z2{iLLxV-V&F?8tgFK|D1_5?XFd*rwB_~M0gC-MFRUmjK$&YeR04}AA`dL4di{#8a9 zw-^D*${`K?2&LIjwc9P74GT|e2_EynTI)5NBc`eKmjBQx!2E{P|f! json) { return ParentDossier( - id: json['id']?.toString() ?? '', + id: json['user_id']?.toString() ?? json['id']?.toString() ?? '', email: json['email']?.toString() ?? '', prenom: json['prenom']?.toString(), nom: json['nom']?.toString(), @@ -194,7 +200,27 @@ class EnfantDossier { String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); + static String? _optionalPhotoUrl(dynamic v) { + if (v == null) return null; + if (v is String) { + final s = v.trim(); + return s.isEmpty ? null : s; + } + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + factory EnfantDossier.fromJson(Map json) { + final rawPhoto = json['photo_url'] ?? json['photoUrl']; + final resolvedPhoto = _optionalPhotoUrl(rawPhoto); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} ' + 'prénom=${json['first_name'] ?? json['prenom']} | ' + 'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → ' + 'résolu=${resolvedPhoto ?? "∅"}', + ); + } return EnfantDossier( id: json['id']?.toString() ?? '', firstName: (json['first_name'] ?? json['prenom'])?.toString(), @@ -203,8 +229,9 @@ class EnfantDossier { gender: (json['gender'] ?? json['genre'])?.toString(), status: json['status']?.toString(), dueDate: json['due_date']?.toString(), - photoUrl: json['photo_url']?.toString(), - consentPhoto: json['consent_photo'] == true, + photoUrl: resolvedPhoto, + consentPhoto: + json['consent_photo'] == true || json['consentPhoto'] == true, ); } } diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 9687898..5e8892f 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -1,7 +1,6 @@ import 'dart:io'; // Pour File import '../models/card_assets.dart'; // Import de l'enum CardColorVertical import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; // import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé class ParentData { @@ -29,25 +28,61 @@ class ParentData { } class ChildData { + static const Object _unsetImage = Object(); + static const Object _unsetImageBytes = Object(); + String firstName; String lastName; String dob; // Date de naissance ou prévisionnelle + /// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi. + String genre; bool photoConsent; bool multipleBirth; bool isUnbornChild; File? imageFile; + /// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web). + Uint8List? imageBytes; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte ChildData({ this.firstName = '', this.lastName = '', this.dob = '', + this.genre = '', this.photoConsent = false, this.multipleBirth = false, this.isUnbornChild = false, this.imageFile, + this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur }); + + ChildData copyWith({ + String? firstName, + String? lastName, + String? dob, + String? genre, + bool? photoConsent, + bool? multipleBirth, + bool? isUnbornChild, + Object? imageFile = _unsetImage, + Object? imageBytes = _unsetImageBytes, + CardColorVertical? cardColor, + }) { + return ChildData( + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + dob: dob ?? this.dob, + genre: genre ?? this.genre, + photoConsent: photoConsent ?? this.photoConsent, + multipleBirth: multipleBirth ?? this.multipleBirth, + isUnbornChild: isUnbornChild ?? this.isUnbornChild, + imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, + imageBytes: + identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, + cardColor: cardColor ?? this.cardColor, + ); + } } // Nouvelle classe pour les détails bancaires diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index ae1477c..1db28b2 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; class AdminCreateDialog extends StatefulWidget { final AppUser? initialUser; @@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State { 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 _submit() async { if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: _validateEmail, ); } @@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), - validator: (v) => _required(v, 'Téléphone'), + label: 'Téléphone (ex: 06 12 34 56 78)', + validator: _validateTelephone, ); } } diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 8dbed7e..a1b5f92 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State { return null; } final base = _required(value, 'Téléphone'); - if (base != null) return base; - final digits = normalizePhone(value!); - if (digits.length != 10) { - return 'Le téléphone doit contenir 10 chiffres'; + if (base != null) { + return base; } - if (!digits.startsWith('0')) { - return 'Le téléphone doit commencer par 0'; - } - return null; + return validateFrenchNationalPhone(value, allowEmpty: false); } String _toTitleCase(String raw) { @@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, readOnly: widget.readOnly, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: widget.readOnly ? null : _validateEmail, ); } @@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, readOnly: widget.readOnly, - keyboardType: TextInputType.phone, - inputFormatters: widget.readOnly - ? null - : [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), + label: 'Téléphone (ex: 06 12 34 56 78)', validator: widget.readOnly ? null : _validatePhone, ); } diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 14a4db1..86df609 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -13,29 +13,15 @@ class AmRegisterStep1Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - PersonalInfoData initialData; - if (registrationData.firstName.isEmpty) { - initialData = PersonalInfoData( - firstName: 'Marie', - lastName: 'DUBOIS', - phone: '0696345678', - email: 'marie.dubois@ptits-pas.fr', - address: '25 Rue de la République', - postalCode: '95870', - city: 'Bezons', - ); - } else { - initialData = PersonalInfoData( - firstName: registrationData.firstName, - lastName: registrationData.lastName, - phone: registrationData.phone, - email: registrationData.email, - address: registrationData.streetAddress, - postalCode: registrationData.postalCode, - city: registrationData.city, - ); - } + final initialData = PersonalInfoData( + firstName: registrationData.firstName, + lastName: registrationData.lastName, + phone: registrationData.phone, + email: registrationData.email, + address: registrationData.streetAddress, + postalCode: registrationData.postalCode, + city: registrationData.city, + ); return PersonalInfoFormScreen( stepText: 'Étape 1/4', diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index bf354d4..362a317 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'dart:io'; import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; @@ -17,24 +15,9 @@ class AmRegisterStep2Screen extends StatefulWidget { class _AmRegisterStep2ScreenState extends State { String? _photoPathFramework; - File? _photoFile; Future _pickPhoto() async { - // TODO: Remplacer par la vraie logique ImagePicker - // final imagePicker = ImagePicker(); - // final pickedFile = await imagePicker.pickImage(source: ImageSource.gallery); - // if (pickedFile != null) { - // setState(() { - // _photoFile = File(pickedFile.path); - // _photoPathFramework = pickedFile.path; - // }); - // } else { - setState(() { - _photoPathFramework = 'assets/images/icon_assmat.png'; - _photoFile = null; - }); - // } - print("Photo sélectionnée: $_photoPathFramework"); + // TODO: brancher ImagePicker ; ne pas préremplir de chemin factice } @override @@ -53,20 +36,6 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { - initialData = ProfessionalInfoData( - photoPath: 'assets/images/icon_assmat.png', - photoConsent: true, - dateOfBirth: DateTime(1980, 6, 8), - birthCity: 'Bezons', - birthCountry: 'France', - nir: '280062A00100191', - agrementNumber: 'AGR-2019-095001', - capacity: 4, - ); - } - return ProfessionalInfoFormScreen( stepText: 'Étape 2/4', title: 'Vos informations professionnelles', diff --git a/frontend/lib/screens/auth/am_register_step3_screen.dart b/frontend/lib/screens/auth/am_register_step3_screen.dart index 7bda43f..3d77845 100644 --- a/frontend/lib/screens/auth/am_register_step3_screen.dart +++ b/frontend/lib/screens/auth/am_register_step3_screen.dart @@ -13,22 +13,13 @@ class AmRegisterStep3Screen extends StatelessWidget { Widget build(BuildContext context) { final data = Provider.of(context, listen: false); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - String initialText = data.presentationText; - bool initialCgu = data.cguAccepted; - - if (initialText.isEmpty) { - initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.'; - initialCgu = true; - } - return PresentationFormScreen( stepText: 'Étape 3/4', title: 'Présentation et Conditions', cardColor: CardColorHorizontal.peach, textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...', - initialText: initialText, - initialCguAccepted: initialCgu, + initialText: data.presentationText, + initialCguAccepted: data.cguAccepted, previousRoute: '/am-register-step2', onSubmit: (text, cguAccepted) { data.updatePresentationAndCgu( diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 2814b72..16ee6d9 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/bug_report_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; @@ -21,6 +22,10 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final GlobalKey> _emailFormKey = + GlobalKey>(); + late final FocusNode _emailFocus; + late final FocusNode _passwordFocus; bool _isLoading = false; String? _errorMessage; @@ -36,22 +41,49 @@ class _LoginPageState extends State { void initState() { super.initState(); _desktopRiverLogoDimensionsFuture = _getImageDimensions(); + _emailFocus = FocusNode(); + _passwordFocus = FocusNode(); + _emailFocus.addListener(_onEmailFocusChange); + } + + void _onEmailFocusChange() { + if (_emailFocus.hasFocus) { + return; + } + // Reporter au frame suivant : une mise à jour synchrone du controller pendant + // un transfert de focus (Tab) peut casser la navigation au clavier. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _emailFocus.hasFocus) { + return; + } + final normalized = normalizeEmailText(_emailController.text); + if (normalized != _emailController.text) { + _emailController.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _emailFormKey.currentState?.validate(); + }); } @override void dispose() { + _emailFocus.removeListener(_onEmailFocusChange); + _emailFocus.dispose(); + _passwordFocus.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } String? _validateEmail(String? value) { - final v = value ?? ''; + final v = value?.trim() ?? ''; if (v.isEmpty) { - return 'Veuillez entrer votre email'; + return 'Veuillez entrer votre adresse e-mail.'; } - if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) { - return 'Veuillez entrer un email valide'; + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; } return null; } @@ -205,53 +237,75 @@ class _LoginPageState extends State { height: h * 0.5, // 50% de la hauteur de l'écran child: Padding( padding: EdgeInsets.all(w * 0.02), // 2% de padding - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Champs côte à côte - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: CustomAppTextField( - controller: _emailController, - labelText: 'Email', - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 53, - fieldWidth: double.infinity, + FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - const SizedBox(width: 20), - Expanded( - child: CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password, - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: - _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 53, - fieldWidth: double.infinity, + const SizedBox(width: 20), + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password, + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - ], + ], + ), ), const SizedBox(height: 20), @@ -461,46 +515,68 @@ class _LoginPageState extends State { horizontal: 24, vertical: 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - CustomAppTextField( - controller: _emailController, - labelText: 'Email', - showLabel: false, - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - const SizedBox(height: 12), - CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - showLabel: false, - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 48, - fieldWidth: double.infinity, - ), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + showLabel: false, + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), + const SizedBox(height: 12), + FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + showLabel: false, + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), if (_errorMessage != null) ...[ const SizedBox(height: 12), Container( @@ -571,6 +647,7 @@ class _LoginPageState extends State { ), ), ), + ), Padding( padding: const EdgeInsets.only(bottom: 12, top: 8), child: Wrap( diff --git a/frontend/lib/screens/auth/parent_register_step1_screen.dart b/frontend/lib/screens/auth/parent_register_step1_screen.dart index bfabab3..becad34 100644 --- a/frontend/lib/screens/auth/parent_register_step1_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step1_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -15,31 +14,15 @@ class ParentRegisterStep1Screen extends StatelessWidget { final registrationData = Provider.of(context, listen: false); final parent1 = registrationData.parent1; - // Générer des données de test si vide - PersonalInfoData initialData; - if (parent1.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); - initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: DataGenerator.address(), - postalCode: DataGenerator.postalCode(), - city: DataGenerator.city(), - ); - } else { - initialData = PersonalInfoData( - firstName: parent1.firstName, - lastName: parent1.lastName, - phone: parent1.phone, - email: parent1.email, - address: parent1.address, - postalCode: parent1.postalCode, - city: parent1.city, - ); - } + final initialData = PersonalInfoData( + firstName: parent1.firstName, + lastName: parent1.lastName, + phone: parent1.phone, + email: parent1.email, + address: parent1.address, + postalCode: parent1.postalCode, + city: parent1.city, + ); return PersonalInfoFormScreen( stepText: 'Étape 1/5', diff --git a/frontend/lib/screens/auth/parent_register_step2_screen.dart b/frontend/lib/screens/auth/parent_register_step2_screen.dart index efa3277..af9b9a2 100644 --- a/frontend/lib/screens/auth/parent_register_step2_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step2_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -19,21 +18,17 @@ class ParentRegisterStep2Screen extends StatelessWidget { bool hasParent2 = parent2 != null; bool sameAddress = false; - // Générer des données de test si vide PersonalInfoData initialData; if (parent2 == null || parent2.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); - sameAddress = DataGenerator.boolean(); - + sameAddress = false; initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: sameAddress ? parent1.address : DataGenerator.address(), - postalCode: sameAddress ? parent1.postalCode : DataGenerator.postalCode(), - city: sameAddress ? parent1.city : DataGenerator.city(), + firstName: parent2?.firstName ?? '', + lastName: parent2?.lastName ?? '', + phone: parent2?.phone ?? '', + email: parent2?.email ?? '', + address: parent2?.address ?? '', + postalCode: parent2?.postalCode ?? '', + city: parent2?.city ?? '', ); } else { sameAddress = (parent2.address == parent1.address && diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index 12e472b..e13b3a9 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -3,15 +3,16 @@ import 'package:google_fonts/google_fonts.dart'; import 'dart:math' as math; // Pour la rotation du chevron import 'package:image_picker/image_picker.dart'; import 'dart:io' show File; +import 'package:flutter/foundation.dart' show kIsWeb; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/custom_navigation_button.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; class ParentRegisterStep3Screen extends StatefulWidget { // final UserRegistrationData registrationData; // Supprimé @@ -75,6 +76,22 @@ class _ParentRegisterStep3ScreenState extends State { super.dispose(); } + /// Même logique que nom / prénom parent : normalisation avant passage à l’étape suivante. + void _normalizeChildrenNamesAndGoToStep4( + BuildContext context, + UserRegistrationData registrationData, + ) { + for (var i = 0; i < registrationData.children.length; i++) { + final c = registrationData.children[i]; + final fn = formatPersonNameCase(c.firstName); + final ln = formatPersonNameCase(c.lastName); + if (fn != c.firstName || ln != c.lastName) { + registrationData.updateChild(i, c.copyWith(firstName: fn, lastName: ln)); + } + } + context.go('/parent-register-step4'); + } + void _scrollListener() { if (!_scrollController.hasClients) return; final position = _scrollController.position; @@ -92,8 +109,6 @@ class _ParentRegisterStep3ScreenState extends State { void _addChild(UserRegistrationData registrationData) { // Prend registrationData setState(() { - bool isUnborn = DataGenerator.boolean(); - // Trouver la première couleur non utilisée CardColorVertical cardColor = _childCardColors.firstWhere( (color) => !_usedColors.contains(color), @@ -102,11 +117,11 @@ class _ParentRegisterStep3ScreenState extends State { final newChild = ChildData( lastName: registrationData.parent1.lastName, - firstName: DataGenerator.firstName(), - dob: DataGenerator.dob(isUnborn: isUnborn), - isUnbornChild: isUnborn, - photoConsent: DataGenerator.boolean(), - multipleBirth: DataGenerator.boolean(), + firstName: '', + dob: '', + isUnbornChild: false, + photoConsent: false, + multipleBirth: false, cardColor: cardColor, ); registrationData.addChild(newChild); @@ -134,23 +149,27 @@ class _ParentRegisterStep3ScreenState extends State { final ImagePicker picker = ImagePicker(); try { final XFile? pickedFile = await picker.pickImage( - source: ImageSource.gallery, imageQuality: 70, maxWidth: 1024, maxHeight: 1024); + source: ImageSource.gallery, + imageQuality: 60, + maxWidth: 900, + maxHeight: 900, + ); if (pickedFile != null) { if (childIndex < registrationData.children.length) { final oldChild = registrationData.children[childIndex]; - final updatedChild = ChildData( - firstName: oldChild.firstName, - lastName: oldChild.lastName, - dob: oldChild.dob, - photoConsent: oldChild.photoConsent, - multipleBirth: oldChild.multipleBirth, - isUnbornChild: oldChild.isUnbornChild, - imageFile: File(pickedFile.path), - cardColor: oldChild.cardColor, - ); + final bytes = await pickedFile.readAsBytes(); + if (bytes.isEmpty) return; + File? file; + if (!kIsWeb) { + try { + final f = File(pickedFile.path); + if (await f.exists()) file = f; + } catch (_) {} + } + final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); registrationData.updateChild(childIndex, updatedChild); } - } + } } catch (e) { print("Erreur image: $e"); } } @@ -188,15 +207,8 @@ class _ParentRegisterStep3ScreenState extends State { ); if (picked != null) { final oldChild = registrationData.children[childIndex]; - final updatedChild = ChildData( - firstName: oldChild.firstName, - lastName: oldChild.lastName, - dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}", - photoConsent: oldChild.photoConsent, - multipleBirth: oldChild.multipleBirth, - isUnbornChild: oldChild.isUnbornChild, - imageFile: oldChild.imageFile, - cardColor: oldChild.cardColor, + final updatedChild = oldChild.copyWith( + dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}", ); registrationData.updateChild(childIndex, updatedChild); } @@ -287,40 +299,42 @@ class _ParentRegisterStep3ScreenState extends State { // Générer les cartes enfants for (int index = 0; index < registrationData.children.length; index++) ...[ ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), - onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), - onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), + onFirstNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(firstName: value)); + }), + onLastNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(lastName: value)); + }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue, - multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent, - multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, onToggleIsUnborn: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), - photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, - imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, @@ -343,7 +357,7 @@ class _ParentRegisterStep3ScreenState extends State { const SizedBox(height: 30), // Boutons navigation en bas du scroll - _buildMobileButtons(context, config, screenSize), + _buildMobileButtons(context, config, screenSize, registrationData), const SizedBox(height: 20), ], ), @@ -393,41 +407,43 @@ class _ParentRegisterStep3ScreenState extends State { return Padding( padding: const EdgeInsets.only(right: 20.0), child: ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), - onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), - onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), + onFirstNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(firstName: value)); + }), + onLastNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(lastName: value)); + }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue, - multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent, - multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, onToggleIsUnborn: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), - photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, - imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, + final oldChild = registrationData.children[index]; + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); + }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, ), @@ -455,7 +471,12 @@ class _ParentRegisterStep3ScreenState extends State { } /// Boutons navigation mobile - Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) { + Widget _buildMobileButtons( + BuildContext context, + DisplayConfig config, + Size screenSize, + UserRegistrationData registrationData, + ) { return Row( children: [ Expanded( @@ -483,7 +504,7 @@ class _ParentRegisterStep3ScreenState extends State { text: 'Suivant', style: NavigationButtonStyle.green, onPressed: () { - context.go('/parent-register-step4'); + _normalizeChildrenNamesAndGoToStep4(context, registrationData); }, width: double.infinity, height: 50, diff --git a/frontend/lib/screens/auth/parent_register_step4_screen.dart b/frontend/lib/screens/auth/parent_register_step4_screen.dart index 10ae74d..f5a4ec8 100644 --- a/frontend/lib/screens/auth/parent_register_step4_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step4_screen.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../models/card_assets.dart'; -import '../../utils/data_generator.dart'; class ParentRegisterStep4Screen extends StatelessWidget { const ParentRegisterStep4Screen({super.key}); @@ -14,22 +13,13 @@ class ParentRegisterStep4Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // Générer un texte de test si vide - String initialText = registrationData.motivationText; - bool initialCgu = registrationData.cguAccepted; - - if (initialText.isEmpty) { - initialText = DataGenerator.motivation(); - initialCgu = true; - } - return PresentationFormScreen( stepText: 'Étape 4/5', title: 'Motivation de votre demande', cardColor: CardColorHorizontal.green, textFieldHint: 'Écrivez ici pour motiver votre demande...', - initialText: initialText, - initialCguAccepted: initialCgu, + initialText: registrationData.motivationText, + initialCguAccepted: registrationData.cguAccepted, previousRoute: '/parent-register-step3', onSubmit: (text, cguAccepted) { registrationData.updateMotivation(text); diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 71047e1..883f9ee 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -13,6 +13,7 @@ import '../../widgets/custom_navigation_button.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; +import '../../services/auth_service.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -22,6 +23,36 @@ class ParentRegisterStep5Screen extends StatefulWidget { } class _ParentRegisterStep5ScreenState extends State { + bool _isSubmitting = false; + + Future _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( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Envoi impossible', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + content: Text(msg, style: GoogleFonts.merienda(fontSize: 14)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + @override Widget build(BuildContext context) { final registrationData = Provider.of(context); @@ -102,12 +133,11 @@ class _ParentRegisterStep5ScreenState extends State { Expanded( child: HoverReliefWidget( child: CustomNavigationButton( - text: 'Soumettre', + text: _isSubmitting ? 'Envoi…' : 'Soumettre', style: NavigationButtonStyle.green, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, + onPressed: _isSubmitting + ? () {} + : () => _submitRegistration(context, registrationData), width: double.infinity, height: 50, fontSize: 16, @@ -118,18 +148,20 @@ class _ParentRegisterStep5ScreenState extends State { ), ) else - ImageButton( - bg: 'assets/images/bg_green.png', - text: 'Soumettre ma demande', - textColor: const Color(0xFF2D6A4F), - width: 350, - height: 50, - fontSize: 18, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, - ), + _isSubmitting + ? const Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ) + : ImageButton( + bg: 'assets/images/bg_green.png', + text: 'Soumettre ma demande', + textColor: const Color(0xFF2D6A4F), + width: 350, + height: 50, + fontSize: 18, + onPressed: () => _submitRegistration(context, registrationData), + ), ], ), ), @@ -216,11 +248,12 @@ class _ParentRegisterStep5ScreenState extends State { childIndex: index, mode: DisplayMode.readonly, onPickImage: () {}, + onClearImage: () {}, onDateSelect: () {}, onFirstNameChanged: (v) {}, onLastNameChanged: (v) {}, + onGenreChanged: (v) {}, onTogglePhotoConsent: (v) {}, - onToggleMultipleBirth: (v) {}, onToggleIsUnborn: (v) {}, onRemove: () {}, canBeRemoved: false, @@ -239,14 +272,14 @@ class _ParentRegisterStep5ScreenState extends State { cardColor: CardColorHorizontal.green, // Changé de pink à green textFieldHint: '', initialText: data.motivationText, - initialCguAccepted: true, // Toujours true ici car déjà passé + initialCguAccepted: data.cguAccepted, previousRoute: '', onSubmit: (t, c) {}, onEdit: () => context.go('/parent-register-step4'), ); } - void _showConfirmationModal(BuildContext context) { + void _showSuccessModal(BuildContext context) { showDialog( context: context, barrierDismissible: false, diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index a219a2d..9d605dd 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -1,6 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:p_tits_pas/config/env.dart'; + class ApiConfig { - // static const String baseUrl = 'http://localhost:3000/api/v1/'; - static const String baseUrl = 'https://app.ptits-pas.fr/api/v1'; + /// Aligné sur [Env.apiBaseUrl] (`--dart-define=API_BASE_URL=...`) pour que les images `/uploads/...` visent le même hôte que l’API. + 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 l’API (chemin type `/uploads/...`). + /// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`. + static String absoluteMediaUrl(String? pathOrUrl) { + if (pathOrUrl == null || pathOrUrl.trim().isEmpty) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")'); + } + return ''; + } + final u = pathOrUrl.trim(); + if (u.startsWith('http://') || u.startsWith('https://')) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u'); + } + return u; + } + final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final out = u.startsWith('/') ? '$base$u' : '$base/$u'; + if (kDebugMode) { + debugPrint( + '[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"', + ); + } + return out; + } // Auth endpoints static const String login = '/auth/login'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index c995b9f..ef4c32d 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; import '../utils/nir_utils.dart'; @@ -183,26 +185,137 @@ class AuthService { return; } - final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null; + final decoded = _tryDecodeJsonMap(response.body); final message = _extractErrorMessage(decoded, response.statusCode); throw Exception(message); } + /// Inscription parent complète (POST /auth/register/parent). + /// Succès : 201, pas de session — rediriger vers le login. + static Future 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 l’envoi (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? _tryDecodeJsonMap(String body) { + if (body.isEmpty) return null; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return Map.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() + .where((s) => s.isNotEmpty) + .toList(); + if (parts.isEmpty) return null; + return parts.join('. '); + } + if (field is Map) { + if (field['message'] != null) { + final inner = _flattenApiMessageField(field['message']); + if (inner != null) return inner; + } + final err = field['error']; + if (err is String && err.trim().isNotEmpty) return err.trim(); + } + return null; + } + /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). static String _extractErrorMessage(dynamic decoded, int statusCode) { const fallback = 'Erreur lors de l\'inscription'; - if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; - final msg = decoded['message']; - if (msg == null) { - final err = decoded['error']; - return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)'; + if (decoded == null || decoded is! Map) { + return '$fallback ($statusCode)'; } - if (msg is String) return msg; - if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); - if (msg is Map && msg['message'] != null) return msg['message'].toString(); + final map = decoded; + final fromMessage = _flattenApiMessageField(map['message']); + if (fromMessage != null) return fromMessage; + final err = map['error']; + if (err is String && err.trim().isNotEmpty) return err.trim(); return '$fallback ($statusCode)'; } + /// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à l’inscription. + static String _humanizeParentRegistrationHttpError(String msg, int statusCode) { + if (statusCode == 409) return msg; + + final lower = msg.toLowerCase().trim(); + final looksLikeGenericServerError = lower == 'internal server error' || + lower == 'internal server error.' || + lower.contains('internal server error'); + + if (statusCode >= 500 && looksLikeGenericServerError) { + return 'Impossible d\'enregistrer votre demande pour le moment. ' + 'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : ' + 'connectez-vous ou utilisez une autre adresse pour le parent principal ' + '(et pour le co-parent si vous en indiquez un). ' + 'Si le problème continue, réessayez plus tard ou contactez le support.'; + } + + if (statusCode >= 500) { + if (lower.contains('duplicate') || + lower.contains('unique constraint') || + lower.contains('23505') || + lower.contains('already exist')) { + return 'Cette adresse e-mail semble déjà enregistrée. ' + 'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.'; + } + } + return msg; + } + /// Rafraîchit le profil utilisateur depuis l'API static Future refreshCurrentUser() async { final token = await TokenService.getToken(); diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a70d011..c0ab07e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -92,8 +93,12 @@ class UserService { /// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107. static Future getDossier(String numeroDossier) async { final encoded = Uri.encodeComponent(numeroDossier); + final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'); + if (kDebugMode) { + debugPrint('[PetitsPas/dossier] GET $uri'); + } final response = await http.get( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), + uri, headers: await _headers(), ); if (response.statusCode == 404) { @@ -113,7 +118,21 @@ class UserService { if (decoded is! Map) { throw FormatException('Réponse invalide'); } - return DossierUnifie.fromJson(Map.from(decoded)); + final dossier = DossierUnifie.fromJson(Map.from(decoded)); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] réponse OK type=${dossier.type} | ' + 'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}', + ); + if (dossier.isFamily) { + final f = dossier.asFamily; + debugPrint( + '[PetitsPas/dossier] famille ${f.numeroDossier} | ' + '${f.enfants.length} enfant(s)', + ); + } + } + return dossier; } catch (e) { if (e is FormatException) rethrow; throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}'); diff --git a/frontend/lib/utils/data_generator.dart b/frontend/lib/utils/data_generator.dart deleted file mode 100644 index d7ffde5..0000000 --- a/frontend/lib/utils/data_generator.dart +++ /dev/null @@ -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 _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 _lastNames = [ - 'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand', 'Leroy', 'Moreau', - 'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David', 'Bertrand', 'Roux', 'Vincent', 'Fournier' - ]; - - static final List _addressSuffixes = [ - 'Rue de la Paix', 'Boulevard des Rêves', 'Avenue du Soleil', 'Place des Étoiles', 'Chemin des Champs' - ]; - - static final List _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 chosenSnippets = []; - while(chosenSnippets.length < count) { - String snippet = _motivationSnippets[_random.nextInt(_motivationSnippets.length)]; - if (!chosenSnippets.contains(snippet)) { - chosenSnippets.add(snippet); - } - } - return chosenSnippets.join(' '); - } -} \ No newline at end of file diff --git a/frontend/lib/utils/email_utils.dart b/frontend/lib/utils/email_utils.dart new file mode 100644 index 0000000..a6ac257 --- /dev/null +++ b/frontend/lib/utils/email_utils.dart @@ -0,0 +1,57 @@ +import 'package:flutter/services.dart'; + +/// Longueur maximale d’une adresse e-mail (RFC 5321). +const int kEmailMaxLength = 254; + +/// Trim + minuscules (usage à la perte de focus et avant validation côté API). +String normalizeEmailText(String raw) { + return raw.trim().toLowerCase(); +} + +/// Motif volontairement simple pour l’UI (pas une validation RFC complète). +/// Local + @ + domaine avec au moins un point ; pas d’espaces. +final RegExp kAppEmailPattern = RegExp( + r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$', +); + +/// Indique si [trimmed] est un e-mail plausible (insensible à la casse). +bool isValidEmailFormat(String trimmed) { + final s = normalizeEmailText(trimmed); + if (s.isEmpty || s.length > kEmailMaxLength) { + return false; + } + return kAppEmailPattern.hasMatch(s); +} + +/// Validation centralisée pour les champs e-mail. +/// +/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée. +String? validateEmail(String? raw, {bool allowEmpty = false}) { + final s = normalizeEmailText(raw ?? ''); + if (s.isEmpty) { + return allowEmpty ? null : 'L’adresse e-mail est obligatoire.'; + } + if (s.length > kEmailMaxLength) { + return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).'; + } + if (!kAppEmailPattern.hasMatch(s)) { + return 'Le format de l’adresse e-mail est incorrect.'; + } + return null; +} + +/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]). +class EmailMaxLengthFormatter extends TextInputFormatter { + const EmailMaxLengthFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length <= kEmailMaxLength) { + return newValue; + } + return oldValue; + } +} diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart new file mode 100644 index 0000000..feddec5 --- /dev/null +++ b/frontend/lib/utils/name_format_utils.dart @@ -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 = {'-', "'", '’'}; + 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(); +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart new file mode 100644 index 0000000..05f8d79 --- /dev/null +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -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 apiGenres = {'H', 'F', 'Autre'}; + + /// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent. + static String? validateForApi(UserRegistrationData d) { + final p1 = d.parent1; + final p1Email = normalizeEmailText(p1.email); + if (p1Email.isEmpty) { + return 'L’email du parent principal est requis.'; + } + if (!isValidEmailFormat(p1Email)) { + return 'L’email du parent principal n’est pas au bon format.'; + } + if (p1.firstName.trim().length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + if (p1.lastName.trim().length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + final tel = _normalizePhone(p1.phone); + if (!_phoneFr.hasMatch(tel)) { + return 'Le numéro de téléphone du parent principal n’est pas valide (ex. 0612345678).'; + } + if (!d.cguAccepted) { + return 'Vous devez accepter les CGU et la politique de confidentialité.'; + } + if (d.children.isEmpty) { + return 'Au moins un enfant est requis.'; + } + + final p2 = d.parent2; + if (p2 != null) { + final any = p2.email.trim().isNotEmpty || + p2.firstName.trim().isNotEmpty || + p2.lastName.trim().isNotEmpty || + p2.phone.trim().isNotEmpty; + if (any) { + final p2Email = normalizeEmailText(p2.email); + if (p2Email.isEmpty || + p2.firstName.trim().length < 2 || + p2.lastName.trim().length < 2) { + return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).'; + } + if (!isValidEmailFormat(p2Email)) { + return 'L’email du co-parent n’est pas au bon format.'; + } + if (p2Email == p1Email) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + final tel2 = _normalizePhone(p2.phone); + if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) { + return 'Le numéro de téléphone du co-parent n’est pas valide.'; + } + } + } + + for (var i = 0; i < d.children.length; i++) { + final c = d.children[i]; + final label = 'Enfant ${i + 1}'; + if (!c.photoConsent) { + return '$label : le consentement photo est obligatoire.'; + } + if (c.isUnbornChild) { + final due = _ddMmYyyyToIso(c.dob); + if (due == null) { + return '$label : indiquez une date de naissance prévisionnelle.'; + } + if (!apiGenres.contains(c.genre)) { + return '$label : indiquez Fille, Garçon ou Inconnu.'; + } + } else { + if (c.firstName.trim().length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + final birth = _ddMmYyyyToIso(c.dob); + if (birth == null) { + return '$label : indiquez une date de naissance valide.'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez Fille ou Garçon.'; + } + } + } + + return null; + } + + static Map toJson(UserRegistrationData d) { + final p1 = d.parent1; + final tel = _normalizePhone(p1.phone); + + final body = { + '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 _childToJson(ChildData c, int index, String parentNom) { + final map = { + '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 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')}'; + } +} diff --git a/frontend/lib/utils/phone_utils.dart b/frontend/lib/utils/phone_utils.dart index 2a87b8c..ba5bde7 100644 --- a/frontend/lib/utils/phone_utils.dart +++ b/frontend/lib/utils/phone_utils.dart @@ -8,46 +8,127 @@ String normalizePhone(String raw) { return digits.length > 10 ? digits.substring(0, 10) : digits; } +/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`. +/// Chaîne vide : `true` (pas encore de saisie). +bool frenchNationalPhoneStartsWithZero(String digitsOnly) { + if (digitsOnly.isEmpty) { + return true; + } + return digitsOnly.startsWith('0'); +} + +/// Validation téléphone France : 10 chiffres, commence par `0`, 2ᵉ chiffre 1–9 (format national). +/// +/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.). +/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée. +String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) { + final trimmed = raw?.trim() ?? ''; + if (trimmed.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + final digits = normalizePhone(trimmed); + if (digits.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + if (!frenchNationalPhoneStartsWithZero(digits)) { + return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).'; + } + if (digits.length < 10) { + return 'Le numéro doit contenir 10 chiffres.'; + } + if (digits.length > 10) { + return 'Le numéro ne peut pas dépasser 10 chiffres.'; + } + if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) { + return 'Numéro de téléphone invalide.'; + } + return null; +} + /// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78"). /// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.). String formatPhoneForDisplay(String raw) { - if (raw.trim().isEmpty) return raw; + if (raw.trim().isEmpty) { + return raw; + } final normalized = normalizePhone(raw); - if (normalized.isEmpty) return raw; + if (normalized.isEmpty) { + return raw; + } + return formatFrenchPhoneDigits(normalized); +} + +/// Affiche les chiffres par paires (ex. `0612345678` → `06 12 34 56 78`). +String formatFrenchPhoneDigits(String normalizedDigits) { + if (normalizedDigits.isEmpty) { + return ''; + } final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); + for (var i = 0; i < normalizedDigits.length; i++) { + if (i > 0 && i.isEven) { + buffer.write(' '); + } + buffer.write(normalizedDigits[i]); } return buffer.toString(); } -/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres. +int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) { + final k = digitCountBeforeCursor.clamp(0, normalized.length); + if (k == 0) { + return 0; + } + return formatFrenchPhoneDigits(normalized.substring(0, k)).length; +} + +/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres. +/// +/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` → `06`). +/// - Si l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`). +/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté. class FrenchPhoneNumberFormatter extends TextInputFormatter { const FrenchPhoneNumberFormatter(); + static const String _autoPrefixFirstDigits = '1234567'; + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; - final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); - } - final formatted = buffer.toString(); + var digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + var didPrependZero = false; + + if (digits.isNotEmpty) { + final first = digits[0]; + if (first == '8' || first == '9') { + return oldValue; + } + if (first != '0' && _autoPrefixFirstDigits.contains(first)) { + digits = '0$digits'; + didPrependZero = true; + if (digits.length > 10) { + digits = digits.substring(0, 10); + } + } else if (first != '0') { + return oldValue; + } + } + + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final formatted = formatFrenchPhoneDigits(normalized); - // Conserver la position du curseur : compter les chiffres avant la sélection final sel = newValue.selection; - final digitsBeforeCursor = newValue.text + var digitsBeforeCursor = newValue.text .substring(0, sel.start.clamp(0, newValue.text.length)) .replaceAll(RegExp(r'\D'), '') .length; - final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); - final clampedOffset = newOffset.clamp(0, formatted.length); + if (didPrependZero) { + digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length); + } + + final clampedOffset = + _cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length); return TextEditingValue( text: formatted, @@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter { ); } } + +/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.). +final List frenchPhoneInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + const FrenchPhoneNumberFormatter(), +]; diff --git a/frontend/lib/utils/postal_utils.dart b/frontend/lib/utils/postal_utils.dart new file mode 100644 index 0000000..d3dfb4d --- /dev/null +++ b/frontend/lib/utils/postal_utils.dart @@ -0,0 +1,19 @@ +import 'package:flutter/services.dart'; + +/// Saisie code postal français : uniquement des chiffres, au plus 5. +final List 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; +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index 6c445d8..b99fd50 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart'; /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. @@ -182,6 +183,9 @@ class _ParametresPanelState extends State { hintText: 'admin@example.com', ), keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], ), actions: [ TextButton( @@ -191,7 +195,17 @@ class _ParametresPanelState extends State { FilledButton( onPressed: () { final t = c.text.trim(); - if (t.isNotEmpty) Navigator.pop(ctx, t); + if (t.isEmpty) { + return; + } + final err = validateEmail(t, allowEmpty: true); + if (err != null) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(err)), + ); + return; + } + Navigator.pop(ctx, t); }, child: const Text('Envoyer'), ), diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 12327b7..614ecee 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { return false; } + if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) { + return false; + } for (final day in _days) { final ferme = _closedByDay[day] ?? false; @@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { TextField( controller: _ligneFixeCtrl, keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], + inputFormatters: frenchPhoneInputFormatters, decoration: const InputDecoration( labelText: 'Ligne fixe', hintText: '01 23 45 67 89', diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index e15bae5..5a18332 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State { static const double _proColumnMinWidth = 260; static const double _photoColumnMinWidth = 160; - /// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API. - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - final base = ApiConfig.baseUrl; - final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); - return u.startsWith('/') ? '$origin$u' : '$origin/$u'; - } + /// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`). + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); Widget _buildPhotoSection(AppUser u) { final photoUrl = _fullPhotoUrl(u.photoUrl); @@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State { ], ), ) - : Image.network( - photoUrl, + : AuthNetworkImage( + url: photoUrl, fit: BoxFit.cover, width: pw, height: ph, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index de89bd9..4354ab6 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State { 3: [2, 5] }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - final base = ApiConfig.baseUrl; - final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); - return u.startsWith('/') ? '$origin$u' : '$origin/$u'; - } + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override Widget build(BuildContext context) { @@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State { /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). Widget _buildEnfantCard(EnfantDossier e) { final photoUrl = _fullPhotoUrl(e.photoUrl); + if (kDebugMode) { + debugPrint( + '[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | ' + 'photoUrl modèle=${e.photoUrl ?? "∅"} | url affichée=${photoUrl.isEmpty ? "∅" : photoUrl}', + ); + } final columnStatusLabel = _enfantColumnStatusLabel(e); return ClipRRect( borderRadius: BorderRadius.circular(_enfantCardRadius), @@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State { } Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { + const photoRadius = 8.0; return Container( width: width, height: height, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(photoRadius), border: Border.all(color: Colors.black.withValues(alpha: 0.08)), boxShadow: [ BoxShadow( @@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State { child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), ), ) - : Image.network( - photoUrl, - fit: BoxFit.contain, + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, width: width, height: height, errorBuilder: (_, __, ___) => ColoredBox( diff --git a/frontend/lib/widgets/app_custom_checkbox.dart b/frontend/lib/widgets/app_custom_checkbox.dart index a52640c..5331ab0 100644 --- a/frontend/lib/widgets/app_custom_checkbox.dart +++ b/frontend/lib/widgets/app_custom_checkbox.dart @@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget { final double checkboxSize; final double checkmarkSizeFactor; final double fontSize; + /// Survol (desktop) ou appui long (mobile) pour afficher un texte d’aide. + final String? tooltip; const AppCustomCheckbox({ super.key, @@ -17,48 +19,67 @@ class AppCustomCheckbox extends StatelessWidget { this.checkboxSize = 20.0, this.checkmarkSizeFactor = 1.4, this.fontSize = 16.0, + this.tooltip, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onChanged(!value), // Inverse la valeur au clic - behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: checkboxSize, - height: checkboxSize, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - Image.asset( - 'assets/images/square.png', - height: checkboxSize, - width: checkboxSize, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onChanged(!value), + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: checkboxSize, + height: checkboxSize, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Image.asset( + 'assets/images/square.png', + height: checkboxSize, + width: checkboxSize, + ), + if (value) + Image.asset( + 'assets/images/coche.png', + height: checkboxSize * checkmarkSizeFactor, + width: checkboxSize * checkmarkSizeFactor, + ), + ], ), - if (value) - Image.asset( - 'assets/images/coche.png', - height: checkboxSize * checkmarkSizeFactor, - width: checkboxSize * checkmarkSizeFactor, - ), - ], - ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - const SizedBox(width: 10), - // Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long - Flexible( - child: Text( - label, - style: GoogleFonts.merienda(fontSize: fontSize), - overflow: TextOverflow.ellipsis, // Gérer le texte long + ), + if (tooltip != null && tooltip!.trim().isNotEmpty) ...[ + const SizedBox(width: 6), + Tooltip( + message: tooltip!.trim(), + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: fontSize * 1.2, + color: Colors.black54, ), ), ], - ), + ], ); } } \ No newline at end of file diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 18bdf72..7d93ecc 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,14 +1,41 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'dart:io' show File; import 'package:flutter/foundation.dart' show kIsWeb; import '../models/user_registration_data.dart'; import '../models/card_assets.dart'; import 'custom_app_text_field.dart'; import 'form_field_wrapper.dart'; -import 'app_custom_checkbox.dart'; import 'hover_relief_widget.dart'; import '../config/display_config.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +const String _photoConsentTooltip = + 'Obligatoire : cochez cette case pour autoriser l’utilisation de la photo de l’enfant.\n' + 'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n' + 'Dans le respect de la politique de confidentialité.'; + +/// Cadre photo (centre transparent), même dossier que `photo.png`. +const String _photoSketchFrameAsset = 'assets/images/photo_frame.png'; + +bool _hasChildPhoto(ChildData c) { + final b = c.imageBytes; + if (b != null && b.isNotEmpty) return true; + return c.imageFile != null; +} + +Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { + final bytes = c.imageBytes; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory(bytes, fit: fit); + } + final f = c.imageFile; + if (f != null) { + return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit); + } + return Image.asset('assets/images/photo.png', fit: BoxFit.contain); +} /// Widget pour afficher et éditer une carte enfant /// Utilisé dans le workflow d'inscription des parents @@ -16,11 +43,14 @@ class ChildCardWidget extends StatefulWidget { final ChildData childData; final int childIndex; final VoidCallback onPickImage; + /// Retire la photo sélectionnée (placeholder à la place). + final VoidCallback onClearImage; final VoidCallback onDateSelect; final ValueChanged onFirstNameChanged; final ValueChanged onLastNameChanged; + /// `H`, `F` ou `Autre` (API). « Inconnu » à l’UI = `Autre`. + final ValueChanged onGenreChanged; final ValueChanged onTogglePhotoConsent; - final ValueChanged onToggleMultipleBirth; final ValueChanged onToggleIsUnborn; final VoidCallback onRemove; final bool canBeRemoved; @@ -32,11 +62,12 @@ class ChildCardWidget extends StatefulWidget { required this.childData, required this.childIndex, required this.onPickImage, + required this.onClearImage, required this.onDateSelect, required this.onFirstNameChanged, required this.onLastNameChanged, + required this.onGenreChanged, required this.onTogglePhotoConsent, - required this.onToggleMultipleBirth, required this.onToggleIsUnborn, required this.onRemove, required this.canBeRemoved, @@ -49,10 +80,24 @@ class ChildCardWidget extends StatefulWidget { } class _ChildCardWidgetState extends State { + /// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG → portrait ~1:2 ; même idée qu’avant #78 : 345×1,1 pour 200 px de côté). + static double _desktopEditableCardWidth({ + required double photoSide, + required double maxWidth, + required double scaleFactor, + }) { + final fromPhoto = photoSide * (345.0 * 1.1 / 200.0); + final minForFields = 300.0 + 44.0 * scaleFactor; + return math.min(maxWidth, math.max(minForFields, fromPhoto)); + } + late TextEditingController _firstNameController; late TextEditingController _lastNameController; late TextEditingController _dobController; + FocusNode? _firstNameFocus; + FocusNode? _lastNameFocus; + @override void initState() { super.initState(); @@ -65,6 +110,38 @@ class _ChildCardWidgetState extends State { _firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text)); _lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text)); // Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici + + if (widget.mode == DisplayMode.editable) { + _firstNameFocus = FocusNode(); + _lastNameFocus = FocusNode(); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _lastNameFocus!.addListener(_onLastNameFocusChange); + } + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override @@ -85,6 +162,10 @@ class _ChildCardWidgetState extends State { @override void dispose() { + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.dispose(); + _lastNameFocus?.dispose(); _firstNameController.dispose(); _lastNameController.dispose(); _dobController.dispose(); @@ -110,16 +191,25 @@ class _ChildCardWidgetState extends State { ); } - final File? currentChildImage = widget.childData.imageFile; - // ... (reste du code existant pour mobile/editable) final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender ? Colors.purple.shade200 : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); - final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90); - final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); + final Color initialPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow); + final Color hoverPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.32), baseCardColorForShadow); + + final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); + final double editableCardWidth = config.isMobile + ? double.infinity + : _desktopEditableCardWidth( + photoSide: photoSide, + maxWidth: screenSize.width * 0.92, + scaleFactor: scaleFactor, + ); return Container( - width: config.isMobile ? double.infinity : screenSize.width * 0.6, + width: editableCardWidth, // On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes // height: config.isMobile ? null : 600.0 * scaleFactor, padding: EdgeInsets.all(22.0 * scaleFactor), @@ -132,24 +222,20 @@ class _ChildCardWidgetState extends State { Column( mainAxisSize: MainAxisSize.min, children: [ - // ... (contenu existant) - HoverReliefWidget( - onPressed: config.isReadonly ? null : widget.onPickImage, - borderRadius: BorderRadius.circular(10), - initialShadowColor: initialPhotoShadow, - hoverShadowColor: hoverPhotoShadow, - child: SizedBox( - height: 200.0 * (config.isMobile ? 0.8 : 1.0), - width: 200.0 * (config.isMobile ? 0.8 : 1.0), - child: Center( - child: Padding( - padding: EdgeInsets.all(5.0 * scaleFactor), - child: currentChildImage != null - ? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover)) - : Image.asset('assets/images/photo.png', fit: BoxFit.contain), - ), - ), - ), + _buildEditablePhotoArea( + context: context, + config: config, + scaleFactor: scaleFactor, + photoSide: photoSide, + childData: widget.childData, + initialPhotoShadow: initialPhotoShadow, + hoverPhotoShadow: hoverPhotoShadow, + ), + SizedBox(height: 8.0 * scaleFactor), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: config.isMobile ? 13.0 : 16.0, ), SizedBox(height: 10.0 * scaleFactor), Row( @@ -173,13 +259,16 @@ class _ChildCardWidgetState extends State { ], ), SizedBox(height: 8.0 * scaleFactor), + _buildGenreSegment(context, config, scaleFactor), + SizedBox(height: 8.0 * scaleFactor), _buildField( config: config, scaleFactor: scaleFactor, label: 'Prénom', controller: _firstNameController, - hint: 'Facultatif si à naître', + hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom', isRequired: !widget.childData.isUnbornChild, + focusNode: _firstNameFocus, ), SizedBox(height: 5.0 * scaleFactor), _buildField( @@ -188,6 +277,7 @@ class _ChildCardWidgetState extends State { label: 'Nom', controller: _lastNameController, hint: 'Nom de l\'enfant', + focusNode: _lastNameFocus, ), SizedBox(height: 8.0 * scaleFactor), _buildField( @@ -200,27 +290,6 @@ class _ChildCardWidgetState extends State { onTap: config.isReadonly ? null : widget.onDateSelect, suffixIcon: Icons.calendar_today, ), - SizedBox(height: 10.0 * scaleFactor), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent, - checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor, - fontSize: config.isMobile ? 13.0 : 16.0, - ), - SizedBox(height: 5.0 * scaleFactor), - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth, - checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor, - fontSize: config.isMobile ? 13.0 : 16.0, - ), - ], - ), ], ), if (widget.canBeRemoved && !config.isReadonly) @@ -230,7 +299,7 @@ class _ChildCardWidgetState extends State { onTap: widget.onRemove, customBorder: const CircleBorder(), child: Image.asset( - 'assets/images/red_cross2.png', + 'assets/images/cross.png', width: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36, fit: BoxFit.contain, @@ -252,6 +321,96 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildEditablePhotoArea({ + required BuildContext context, + required DisplayConfig config, + required double scaleFactor, + required double photoSide, + required ChildData childData, + required Color initialPhotoShadow, + required Color hoverPhotoShadow, + }) { + final canInteract = !config.isReadonly; + final hasPhoto = _hasChildPhoto(childData); + final outerRadius = BorderRadius.circular(10 * scaleFactor); + // Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor). + final photoClipRadius = BorderRadius.circular(photoSide * 0.14); + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + HoverReliefWidget( + onPressed: canInteract ? widget.onPickImage : null, + borderRadius: outerRadius, + initialElevation: 10, + hoverElevation: 16, + initialShadowColor: initialPhotoShadow, + hoverShadowColor: hoverPhotoShadow, + // Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué). + clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias, + child: SizedBox( + width: photoSide, + height: photoSide, + child: !hasPhoto + ? ClipRRect( + borderRadius: outerRadius, + child: Center( + child: Image.asset( + 'assets/images/photo.png', + fit: BoxFit.contain, + width: photoSide, + height: photoSide, + ), + ), + ) + : Stack( + fit: StackFit.expand, + children: [ + // Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle). + Positioned.fill( + child: ClipRRect( + borderRadius: photoClipRadius, + child: _buildChildPhotoImage(childData, fit: BoxFit.cover), + ), + ), + Positioned.fill( + child: Image.asset( + _photoSketchFrameAsset, + fit: BoxFit.fill, + errorBuilder: (context, error, stackTrace) => + const SizedBox.shrink(), + ), + ), + ], + ), + ), + ), + if (hasPhoto && canInteract) + Positioned( + top: 8 * scaleFactor, + right: 8 * scaleFactor, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onClearImage, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(4), + child: Image.asset( + 'assets/images/cross.png', + width: config.isMobile ? 26 : 30, + height: config.isMobile ? 26 : 30, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ], + ); + } + /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal) Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) @@ -266,8 +425,7 @@ class _ChildCardWidgetState extends State { else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path; else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path; else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path; - - final File? currentChildImage = widget.childData.imageFile; + final cardWidth = screenSize.width / 2.0; return SizedBox( @@ -310,32 +468,41 @@ class _ChildCardWidgetState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // PHOTO (1/3) + // PHOTO (1/3) + consentement sous la photo Expanded( flex: 1, child: Center( - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], ), - ], + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: _hasChildPhoto(widget.childData) + ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) + : Image.asset('assets/images/photo.png', fit: BoxFit.contain), + ), + ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: currentChildImage != null - ? (kIsWeb - ? Image.network(currentChildImage.path, fit: BoxFit.cover) - : Image.file(currentChildImage, fit: BoxFit.cover)) - : Image.asset('assets/images/photo.png', fit: BoxFit.contain), + const SizedBox(height: 12), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 16.0, ), - ), + ], ), ), ), @@ -356,35 +523,14 @@ class _ChildCardWidgetState extends State { widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', _dobController.text ), + const SizedBox(height: 12), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), ], ), ), - const SizedBox(height: 18), - - // Consentements - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: (v) {}, // Readonly - checkboxSize: 22.0, - fontSize: 16.0, - ), - const SizedBox(width: 32), - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: (v) {}, // Readonly - checkboxSize: 22.0, - fontSize: 16.0, - ), - ], - ), ], ), ), @@ -396,8 +542,6 @@ class _ChildCardWidgetState extends State { /// Carte en mode readonly MOBILE avec hauteur adaptative Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) { - final File? currentChildImage = widget.childData.imageFile; - return Container( width: double.infinity, // Pas de height fixe @@ -452,14 +596,18 @@ class _ChildCardWidgetState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(15), - child: currentChildImage != null - ? (kIsWeb - ? Image.network(currentChildImage.path, fit: BoxFit.cover) - : Image.file(currentChildImage, fit: BoxFit.cover)) + child: _hasChildPhoto(widget.childData) + ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) : Image.asset('assets/images/photo.png', fit: BoxFit.contain), ), ), ), + const SizedBox(height: 10), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 14.0, + ), const SizedBox(height: 16), // Champs @@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State { widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', _dobController.text ), - const SizedBox(height: 16), - - // Consentements - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: (v) {}, - checkboxSize: 20.0, - fontSize: 14.0, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: (v) {}, - checkboxSize: 20.0, - fontSize: 14.0, - ), - ], - ), - ], - ), + const SizedBox(height: 8), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), @@ -525,6 +644,111 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildPhotoConsentRow({ + required BuildContext context, + required DisplayConfig config, + required double labelFontSize, + }) { + final readonly = config.isReadonly; + final primary = Theme.of(context).colorScheme.primary; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: widget.childData.photoConsent, + onChanged: readonly + ? null + : (v) { + if (v != null) widget.onTogglePhotoConsent(v); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + activeColor: primary, + ), + Flexible( + child: Text( + 'Consentement photo', + style: GoogleFonts.merienda(fontSize: labelFontSize), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Tooltip( + message: _photoConsentTooltip, + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: labelFontSize * 1.2, + color: Colors.black54, + ), + ), + ], + ); + } + + static String _genreDisplayLabel(String genre) { + switch (genre) { + case 'F': + return 'Fille'; + case 'H': + return 'Garçon'; + case 'Autre': + return 'Inconnu'; + default: + return '—'; + } + } + + /// Fille / Garçon ; « Inconnu » (`Autre`) uniquement si enfant à naître. + Widget _buildGenreSegment(BuildContext context, DisplayConfig config, double scaleFactor) { + if (config.isReadonly) { + return const SizedBox.shrink(); + } + final selected = widget.childData.genre; + final isUnborn = widget.childData.isUnbornChild; + final fontSize = config.isMobile ? 13.0 : 14.0 * scaleFactor; + final padV = config.isMobile ? 6.0 : 8.0 * scaleFactor; + + Widget seg(String label, String api) { + final on = selected == api; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: OutlinedButton( + onPressed: () => widget.onGenreChanged(api), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: padV), + visualDensity: VisualDensity.compact, + backgroundColor: on ? Colors.black.withValues(alpha: 0.08) : Colors.transparent, + foregroundColor: Colors.black87, + side: BorderSide( + width: on ? 2 : 1, + color: on ? Theme.of(context).primaryColor : Colors.black38, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize, fontWeight: on ? FontWeight.w700 : FontWeight.w500), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + return Row( + children: [ + seg('Fille', 'F'), + seg('Garçon', 'H'), + if (isUnborn) seg('Inconnu', 'Autre'), + ], + ); + } + /// Helper pour champ Readonly style "Beige" Widget _buildReadonlyField(String label, String value) { return Column( @@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State { bool readOnly = false, VoidCallback? onTap, IconData? suffixIcon, + FocusNode? focusNode, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State { } else { return CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, isRequired: isRequired, diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart new file mode 100644 index 0000000..8562963 --- /dev/null +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -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 l’URL n’est 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 l’icô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 createState() => _AuthNetworkImageState(); +} + +class _AuthNetworkImageState extends State { + late final Future?> _headersFuture; + + @override + void initState() { + super.initState(); + final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url); + _headersFuture = + isPublic ? Future?>.value(null) : _loadHeaders(); + if (kDebugMode) { + debugPrint( + '[PetitsPas/image] préparation chargement url=${widget.url} | ' + 'uploadPublic=$isPublic | avecBearer=${!isPublic}', + ); + } + } + + static Future?> _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?>( + 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, + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 967865a..2db1ab3 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget { final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; final List? inputFormatters; + final bool autocorrect; + final bool enableSuggestions; + final GlobalKey>? formFieldKey; const CustomAppTextField({ super.key, @@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget { this.textInputAction, this.onFieldSubmitted, this.inputFormatters, + this.autocorrect = true, + this.enableSuggestions = true, + this.formFieldKey, }); @override @@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State { @override Widget build(BuildContext context) { - const double fontHeightMultiplier = 1.2; - const double internalVerticalPadding = 16.0; final double dynamicFieldHeight = widget.fieldHeight; + // Indication « non éditable » : libellé + hint en gris. + final Color labelColor = + widget.enabled ? Colors.black87 : Colors.grey; + final Color hintColor = widget.enabled + ? Colors.black54.withValues(alpha: 0.7) + : Colors.grey; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State { widget.labelText, style: GoogleFonts.merienda( fontSize: widget.labelFontSize, - color: Colors.black87, + color: labelColor, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 6), ], + // Pas de hauteur fixe sur le TextFormField : le message d’erreur du + // validateur s’affiche en dessous ; un SizedBox fixe le masquait. SizedBox( width: widget.fieldWidth, - height: dynamicFieldHeight, child: Stack( + clipBehavior: Clip.none, alignment: Alignment.centerLeft, children: [ Positioned.fill( @@ -112,48 +124,63 @@ class _CustomAppTextFieldState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), - child: TextFormField( - controller: widget.controller, - focusNode: widget.focusNode, - obscureText: widget.obscureText, - keyboardType: widget.keyboardType, - inputFormatters: widget.inputFormatters, - autofillHints: widget.autofillHints, - textInputAction: widget.textInputAction, - onFieldSubmitted: widget.onFieldSubmitted, - enabled: widget.enabled, - readOnly: widget.readOnly, - onTap: widget.onTap, - style: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: widget.enabled ? Colors.black87 : Colors.grey), - validator: widget.validator ?? - (value) { - if (!widget.enabled || widget.readOnly) return null; - if (widget.isRequired && - (value == null || value.isEmpty)) { - return 'Ce champ est obligatoire'; - } - return null; - }, - decoration: InputDecoration( - hintText: widget.hintText, - hintStyle: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: Colors.black54.withOpacity(0.7)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - suffixIcon: widget.suffixIcon != null - ? Padding( - padding: const EdgeInsets.only(right: 0.0), - child: Icon(widget.suffixIcon, - color: Colors.black54, - size: widget.inputFontSize * 1.1), - ) - : null, - isDense: true, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: + (dynamicFieldHeight - 16.0).clamp(24.0, double.infinity), + ), + child: TextFormField( + key: widget.formFieldKey, + controller: widget.controller, + focusNode: widget.focusNode, + obscureText: widget.obscureText, + keyboardType: widget.keyboardType, + autocorrect: widget.autocorrect, + enableSuggestions: widget.enableSuggestions, + inputFormatters: widget.inputFormatters, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onFieldSubmitted: widget.onFieldSubmitted, + enabled: widget.enabled, + readOnly: widget.readOnly, + onTap: widget.onTap, + style: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: Colors.black87), + validator: widget.validator ?? + (value) { + if (!widget.enabled || widget.readOnly) return null; + if (widget.isRequired && + (value == null || value.isEmpty)) { + return 'Ce champ est obligatoire'; + } + return null; + }, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: hintColor), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + errorStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize * 0.75, + color: Colors.red.shade800, + height: 1.2, + ), + errorMaxLines: 3, + suffixIcon: widget.suffixIcon != null + ? Padding( + padding: const EdgeInsets.only(right: 0.0), + child: Icon(widget.suffixIcon, + color: Colors.black54, + size: widget.inputFontSize * 1.1), + ) + : null, + ), + textAlignVertical: TextAlignVertical.center, ), - textAlignVertical: TextAlignVertical.center, ), ), ], diff --git a/frontend/lib/widgets/email_text_field.dart b/frontend/lib/widgets/email_text_field.dart new file mode 100644 index 0000000..2792702 --- /dev/null +++ b/frontend/lib/widgets/email_text_field.dart @@ -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 createState() => _EmailTextFormFieldState(); +} + +class _EmailTextFormFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + 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 [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? onFieldSubmitted; + + @override + State createState() => _EmailCustomTextFieldState(); +} + +class _EmailCustomTextFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + 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, + ); + } +} diff --git a/frontend/lib/widgets/french_phone_field.dart b/frontend/lib/widgets/french_phone_field.dart new file mode 100644 index 0000000..011150e --- /dev/null +++ b/frontend/lib/widgets/french_phone_field.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +import '../utils/phone_utils.dart'; +import 'custom_app_text_field.dart'; + +/// [TextFormField] téléphone France : formatage (0 automatique si 1–7, etc.) + validation optionnelle. +/// +/// Préférer ce widget ou [frenchPhoneInputFormatters] + [validateFrenchNationalPhone] pour tout nouveau formulaire. +class FrenchPhoneTextFormField extends StatelessWidget { + const FrenchPhoneTextFormField({ + super.key, + required this.controller, + this.decoration, + this.label, + this.hint, + this.allowEmpty = false, + this.readOnly = false, + this.focusNode, + this.autovalidateMode, + this.validator, + }); + + final TextEditingController controller; + final InputDecoration? decoration; + final String? label; + final String? hint; + final bool allowEmpty; + final bool readOnly; + final FocusNode? focusNode; + final AutovalidateMode? autovalidateMode; + final String? Function(String?)? validator; + + @override + Widget build(BuildContext context) { + final deco = decoration ?? + InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ); + + return TextFormField( + controller: controller, + focusNode: focusNode, + readOnly: readOnly, + keyboardType: TextInputType.phone, + inputFormatters: frenchPhoneInputFormatters, + autovalidateMode: autovalidateMode, + decoration: deco, + validator: readOnly + ? null + : (validator ?? + (value) => validateFrenchNationalPhone(value, allowEmpty: allowEmpty)), + ); + } +} + +/// Même logique que [FrenchPhoneTextFormField], avec le rendu [CustomAppTextField] (inscription, cartes, etc.). +class FrenchPhoneCustomTextField extends StatelessWidget { + const FrenchPhoneCustomTextField({ + super.key, + required this.controller, + required this.labelText, + this.hintText = '', + this.focusNode, + this.fieldWidth = double.infinity, + this.fieldHeight = 53.0, + this.labelFontSize = 22.0, + this.inputFontSize = 20.0, + this.enabled = true, + this.readOnly = false, + this.allowEmpty = false, + this.style = CustomAppTextFieldStyle.beige, + this.validator, + this.suffixIcon, + this.onTap, + this.autofillHints, + this.textInputAction, + this.onFieldSubmitted, + }); + + final TextEditingController controller; + final String labelText; + final String hintText; + final FocusNode? focusNode; + final double fieldWidth; + final double fieldHeight; + final double labelFontSize; + final double inputFontSize; + final bool enabled; + final bool readOnly; + final bool allowEmpty; + final CustomAppTextFieldStyle style; + final String? Function(String?)? validator; + final IconData? suffixIcon; + final VoidCallback? onTap; + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final ValueChanged? 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, + ); + } +} diff --git a/frontend/lib/widgets/hover_relief_widget.dart b/frontend/lib/widgets/hover_relief_widget.dart index cee1f79..caa67e8 100644 --- a/frontend/lib/widgets/hover_relief_widget.dart +++ b/frontend/lib/widgets/hover_relief_widget.dart @@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget { final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol final Color initialShadowColor; // Nouveau paramètre final Color hoverShadowColor; // Nouveau paramètre + /// `Clip.none` : ne pas découper le child (ex. trou du cadre PNG par-dessus la photo). + final Clip clipBehavior; const HoverReliefWidget({ required this.child, @@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget { this.enableHoverEffect = true, // Par défaut, l'effet est activé this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15) this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3) + this.clipBehavior = Clip.antiAlias, super.key, }); @@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State { elevation: elevation, shadowColor: shadowColor, borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ), ); @@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State { elevation: widget.initialElevation, // Utilise l'élévation initiale shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ); } diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 7e19729..181cc82 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:go_router/go_router.dart'; import 'dart:math' as math; @@ -76,6 +79,19 @@ class PersonalInfoFormScreen extends StatefulWidget { } class _PersonalInfoFormScreenState extends State { + /// 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(); late TextEditingController _lastNameController; late TextEditingController _firstNameController; @@ -89,13 +105,22 @@ class _PersonalInfoFormScreenState extends State { bool _sameAddress = false; bool _fieldsEnabled = true; + FocusNode? _lastNameFocus; + FocusNode? _firstNameFocus; + FocusNode? _cityFocus; + FocusNode? _emailFocus; + final GlobalKey> _emailFormKey = + GlobalKey>(); + @override void initState() { super.initState(); _lastNameController = TextEditingController(text: widget.initialData.lastName); _firstNameController = TextEditingController(text: widget.initialData.firstName); _phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone)); - _emailController = TextEditingController(text: widget.initialData.email); + _emailController = TextEditingController( + text: normalizeEmailText(widget.initialData.email), + ); _addressController = TextEditingController(text: widget.initialData.address); _postalCodeController = TextEditingController(text: widget.initialData.postalCode); _cityController = TextEditingController(text: widget.initialData.city); @@ -109,10 +134,147 @@ class _PersonalInfoFormScreenState extends State { _sameAddress = widget.initialSameAddress ?? false; _updateAddressFields(); } + + if (widget.mode == DisplayMode.editable) { + _lastNameFocus = FocusNode(); + _firstNameFocus = FocusNode(); + _cityFocus = FocusNode(); + _emailFocus = FocusNode(); + _lastNameFocus!.addListener(_onLastNameFocusChange); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _cityFocus!.addListener(_onCityFocusChange); + _emailFocus!.addListener(_onEmailFocusChange); + } + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onCityFocusChange() { + if (_cityFocus == null || _cityFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_cityController); + } + + void _onEmailFocusChange() { + if (_emailFocus == null || _emailFocus!.hasFocus) { + return; + } + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return; + } + // Reporter normalisation + validate au frame suivant pour ne pas casser Tab. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) { + return; + } + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return; + } + final normalized = normalizeEmailText(_emailController.text); + if (normalized != _emailController.text) { + _emailController.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + // Pas d’erreur « obligatoire » au blur si le champ est encore vide : + // évite un message dès l’activation du parent 2 (focus / rebuild) et + // la soumission du formulaire valide toujours. + if (normalizeEmailText(_emailController.text).isEmpty) { + return; + } + _emailFormKey.currentState?.validate(); + }); + } + + String? _validateRequiredFrenchPhone(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire'; + } + return validateFrenchNationalPhone(value, allowEmpty: false); + } + + String? _validateRequiredEmail(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire.'; + } + return validateEmail(value, allowEmpty: true); + } + + /// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé. + String? _validateFrenchPhoneIfSecondParentNeeded(String? value) { + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return null; + } + return _validateRequiredFrenchPhone(value); + } + + String? _validateEmailIfSecondParentNeeded(String? value) { + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return null; + } + return _validateRequiredEmail(value); + } + + /// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1). + String? _validateAddressLineIfManualEntry(String? value) { + if (!_fieldsEnabled) { + return null; + } + if (widget.showSameAddressCheckbox && _sameAddress) { + return null; + } + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire'; + } + return null; + } + + /// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ». + String? _validatePostalCodeField(String? value) { + if (!_fieldsEnabled) { + return null; + } + if (widget.showSameAddressCheckbox && _sameAddress) { + return null; + } + return validateFrenchPostalCode(value, allowEmpty: false); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override void dispose() { + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _cityFocus?.removeListener(_onCityFocusChange); + _emailFocus?.removeListener(_onEmailFocusChange); + _lastNameFocus?.dispose(); + _firstNameFocus?.dispose(); + _cityFocus?.dispose(); + _emailFocus?.dispose(); _lastNameController.dispose(); _firstNameController.dispose(); _phoneController.dispose(); @@ -131,13 +293,50 @@ class _PersonalInfoFormScreenState extends State { } } + Widget _wrapScrollBodyIfEditable({ + required DisplayConfig config, + required Widget child, + }) { + if (!config.isEditable) return child; + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: child, + ); + } + + Widget _orderedFocus({ + required bool enabled, + required double order, + required Widget child, + }) { + if (!enabled) return child; + return FocusTraversalOrder( + order: NumericFocusOrder(order), + child: child, + ); + } + void _handleSubmit() { + if (widget.mode == DisplayMode.editable) { + _applyPersonNameFormat(_lastNameController); + _applyPersonNameFormat(_firstNameController); + _applyPersonNameFormat(_cityController); + } + // Parent 2 désactivé : pas de contrôle sur les champs (désactivés à l’écran). + if (widget.showSecondPersonToggle && !_hasSecondPerson) { + widget.onSubmit( + PersonalInfoData(), + hasSecondPerson: false, + sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null, + ); + return; + } if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) { final data = PersonalInfoData( firstName: _firstNameController.text, lastName: _lastNameController.text, phone: normalizePhone(_phoneController.text), - email: _emailController.text, + email: normalizeEmailText(_emailController.text), address: _addressController.text, postalCode: _postalCodeController.text, city: _cityController.text, @@ -169,75 +368,84 @@ class _PersonalInfoFormScreenState extends State { Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 40.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.stepText, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 13 : 16, - color: Colors.black54, - ), - ), - SizedBox(height: config.isMobile ? 6 : 10), - Text( - widget.title, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 18 : 24, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: config.isMobile ? 16 : 30), - _buildCard(context, config, screenSize), - - // Boutons mobile sous la carte (dans le scroll) - if (config.isMobile) ...[ - const SizedBox(height: 20), - Padding( - padding: EdgeInsets.symmetric( - horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté) - ), - child: Row( - children: [ - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Précédent', - style: NavigationButtonStyle.purple, - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(widget.previousRoute); - } - }, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - const SizedBox(width: 16), // Écart entre les boutons - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Suivant', - style: NavigationButtonStyle.green, - onPressed: _handleSubmit, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - ], + child: _wrapScrollBodyIfEditable( + config: config, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.stepText, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 13 : 16, + color: Colors.black54, ), ), - const SizedBox(height: 10), + SizedBox(height: config.isMobile ? 6 : 10), + Text( + widget.title, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 18 : 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: config.isMobile ? 16 : 30), + _buildCard(context, config, screenSize), + if (config.isMobile) ...[ + const SizedBox(height: 20), + Padding( + padding: EdgeInsets.symmetric( + horizontal: screenSize.width * 0.05, + ), + child: Row( + children: [ + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavPrevious, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Précédent', + style: NavigationButtonStyle.purple, + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(widget.previousRoute); + } + }, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavNext, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Suivant', + style: NavigationButtonStyle.green, + onPressed: _handleSubmit, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ], ], - ], + ), ), ), ), @@ -522,17 +730,34 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _hasSecondPerson, - onChanged: (value) { - setState(() { - _hasSecondPerson = value; - _fieldsEnabled = value; - }); - }, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSecondPersonToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _hasSecondPerson, + onChanged: (value) { + setState(() { + _hasSecondPerson = value; + _fieldsEnabled = value; + if (value && _sameAddress) { + _updateAddressFields(); + } + }); + if (!value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Si l’utilisateur a déjà recoché Parent 2, ne pas valider : + // sinon des champs vides affichent une erreur tout de suite. + if (!mounted || _hasSecondPerson) { + return; + } + _formKey.currentState?.validate(); + }); + } + }, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -558,17 +783,21 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _sameAddress, - onChanged: _fieldsEnabled ? (value) { - setState(() { - _sameAddress = value; - _updateAddressFields(); - }); - } : null, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSameAddressToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _sameAddress, + onChanged: _fieldsEnabled ? (value) { + setState(() { + _sameAddress = value; + _updateAddressFields(); + }); + } : null, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -606,6 +835,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), ), const SizedBox(width: 20), @@ -616,6 +847,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), ), ], @@ -633,7 +866,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, - inputFormatters: _phoneInputFormatters, + inputFormatters: frenchPhoneInputFormatters, + focusOrder: _focusPhone, + fieldValidator: _validateFrenchPhoneIfSecondParentNeeded, ), ), const SizedBox(width: 20), @@ -645,6 +880,11 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), ), ], @@ -658,6 +898,10 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), SizedBox(height: verticalSpacing), @@ -670,9 +914,12 @@ class _PersonalInfoFormScreenState extends State { config: config, label: 'Code Postal', controller: _postalCodeController, - hint: 'Code postal', + hint: '5 chiffres', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, + fieldValidator: _validatePostalCodeField, + inputFormatters: kFrenchPostalCodeInputFormatters, ), ), const SizedBox(width: 20), @@ -684,6 +931,11 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ), ], @@ -852,6 +1104,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), const SizedBox(height: 12), @@ -862,6 +1116,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), const SizedBox(height: 12), @@ -873,7 +1129,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, - inputFormatters: _phoneInputFormatters, + inputFormatters: frenchPhoneInputFormatters, + focusOrder: _focusPhone, + fieldValidator: _validateFrenchPhoneIfSecondParentNeeded, ), const SizedBox(height: 12), @@ -885,6 +1143,11 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), const SizedBox(height: 12), @@ -895,6 +1158,10 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), const SizedBox(height: 12), @@ -903,9 +1170,12 @@ class _PersonalInfoFormScreenState extends State { config: config, label: 'Code Postal', controller: _postalCodeController, - hint: 'Code postal', + hint: '5 chiffres', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, + fieldValidator: _validatePostalCodeField, + inputFormatters: kFrenchPostalCodeInputFormatters, ), const SizedBox(height: 12), @@ -916,6 +1186,11 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ], ); @@ -930,6 +1205,10 @@ class _PersonalInfoFormScreenState extends State { TextInputType? keyboardType, bool enabled = true, List? inputFormatters, + double? focusOrder, + FocusNode? focusNode, + GlobalKey>? formFieldKey, + String? Function(String?)? fieldValidator, }) { if (config.isReadonly) { // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) @@ -942,9 +1221,12 @@ class _PersonalInfoFormScreenState extends State { value: displayValue, ); } else { - // Mode éditable : style adapté mobile/desktop - return CustomAppTextField( + final effectiveKeyboardType = keyboardType ?? TextInputType.text; + final isEmail = effectiveKeyboardType == TextInputType.emailAddress; + Widget field = CustomAppTextField( + formFieldKey: formFieldKey, controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, style: CustomAppTextFieldStyle.beige, @@ -952,10 +1234,23 @@ class _PersonalInfoFormScreenState extends State { fieldHeight: config.isMobile ? 45.0 : 53.0, labelFontSize: config.isMobile ? 15.0 : 22.0, inputFontSize: config.isMobile ? 14.0 : 20.0, - keyboardType: keyboardType ?? TextInputType.text, + keyboardType: effectiveKeyboardType, + autocorrect: !isEmail, + enableSuggestions: !isEmail, + autofillHints: isEmail ? const [AutofillHints.email] : null, + textInputAction: isEmail ? TextInputAction.next : null, enabled: enabled, inputFormatters: inputFormatters, + validator: fieldValidator, + isRequired: fieldValidator == null, ); + if (focusOrder != null) { + field = FocusTraversalOrder( + order: NumericFocusOrder(focusOrder), + child: field, + ); + } + return field; } } diff --git a/scripts/register-parent-durand-rousseau-test.mjs b/scripts/register-parent-durand-rousseau-test.mjs new file mode 100644 index 0000000..ece7507 --- /dev/null +++ b/scripts/register-parent-durand-rousseau-test.mjs @@ -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 */ + } +} diff --git a/scripts/register-parent-lecomte-test.mjs b/scripts/register-parent-lecomte-test.mjs new file mode 100644 index 0000000..5f9c3f3 --- /dev/null +++ b/scripts/register-parent-lecomte-test.mjs @@ -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(); diff --git a/scripts/register-parent-martin-test.mjs b/scripts/register-parent-martin-test.mjs new file mode 100644 index 0000000..a1253eb --- /dev/null +++ b/scripts/register-parent-martin-test.mjs @@ -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();