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 0000000..9d9beeb Binary files /dev/null and b/frontend/assets/images/photo_frame.png differ diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 83e3450..4ccd422 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:p_tits_pas/models/user.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. @@ -107,7 +108,12 @@ class DossierFamille { numeroDossier: json['numero_dossier']?.toString(), parents: parentsList, enfants: enfantsList, - presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(), + presentation: (json['texte_motivation'] ?? + json['presentation'] ?? + json['presentation_dossier'] ?? + json['texteMotivation'] ?? + json['presentationDossier']) + ?.toString(), ); } @@ -151,7 +157,7 @@ class ParentDossier { factory ParentDossier.fromJson(Map 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();