Compare commits

..

No commits in common. "0befd4eb2a5cd19b3ad7bfc20a5f37af80af060f" and "62b2466cce53b2051e520ce38bb31508987ab31a" have entirely different histories.

42 changed files with 589 additions and 1488 deletions

View File

@ -1,32 +1,27 @@
#!/bin/bash #!/bin/bash
# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql # Test POST /auth/register/am (ticket #90)
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
#
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
# Usage: ./scripts/test-register-am.sh [BASE_URL] # Usage: ./scripts/test-register-am.sh [BASE_URL]
# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1 # Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}" BASE_URL="${1:-http://localhost:3000/api/v1}"
echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)" echo "Testing POST $BASE_URL/auth/register/am"
echo "---" echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "smoke.am.curl@ptits-pas.fr", "email": "marie.dupont.test@ptits-pas.fr",
"prenom": "Smoke", "prenom": "Marie",
"nom": "CURLTEST", "nom": "DUPONT",
"telephone": "0612345678", "telephone": "0612345678",
"adresse": "1 rue Smoke", "adresse": "1 rue Test",
"code_postal": "95870", "code_postal": "75001",
"ville": "Bezons", "ville": "Paris",
"consentement_photo": false, "consentement_photo": true,
"date_naissance": "1986-12-15", "nir": "123456789012345",
"nir": "186127500100279", "numero_agrement": "AGR-2024-001",
"numero_agrement": "AGR-SMOKE-CURL-001", "capacite_accueil": 4,
"capacite_accueil": 3,
"places_disponibles": 2,
"acceptation_cgu": true, "acceptation_cgu": true,
"acceptation_privacy": true "acceptation_privacy": true
}' }'

View File

@ -138,12 +138,6 @@ export class Users {
@Column({ name: 'date_naissance', type: 'date', nullable: true }) @Column({ name: 'date_naissance', type: 'date', nullable: true })
date_naissance?: Date; date_naissance?: Date;
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
lieu_naissance_ville?: string;
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
lieu_naissance_pays?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date; cree_le: Date;

View File

@ -99,10 +99,10 @@ export class MailService {
} }
/** /**
* Accusé de réception inscription (parent ou assistante maternelle) : * Accusé de réception inscription parent : demande enregistrée + n° de dossier.
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade. * Lutilisateur est en attente de validation ; pas de lien création MDP à ce stade.
*/ */
async sendRegistrationPendingEmail( async sendParentRegistrationPendingEmail(
to: string, to: string,
prenom: string, prenom: string,
nom: string, nom: string,

View File

@ -5,7 +5,6 @@ import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
@ -63,10 +62,10 @@ export class AuthController {
description: description:
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).', 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
}) })
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto }) @ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' }) @ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> { async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
return this.authService.inscrireAMComplet(dto); return this.authService.inscrireAMComplet(dto);
} }

View File

@ -376,14 +376,14 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? ''; const numeroDossier = resultat.parent1.numero_dossier ?? '';
try { try {
await this.mailService.sendRegistrationPendingEmail( await this.mailService.sendParentRegistrationPendingEmail(
resultat.parent1.email, resultat.parent1.email,
resultat.parent1.prenom ?? '', resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '', resultat.parent1.nom ?? '',
numeroDossier, numeroDossier,
); );
if (resultat.parent2) { if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail( await this.mailService.sendParentRegistrationPendingEmail(
resultat.parent2.email, resultat.parent2.email,
resultat.parent2.prenom ?? '', resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '', resultat.parent2.nom ?? '',
@ -418,12 +418,6 @@ export class AuthService {
); );
} }
if (dto.places_disponibles > dto.capacite_accueil) {
throw new BadRequestException(
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
);
}
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase(); const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, { const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance, dateNaissance: dto.date_naissance,
@ -502,8 +496,6 @@ export class AuthService {
consentement_photo: dto.consentement_photo, consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto, date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier, numero_dossier: numeroDossier,
}); });
const userEnregistre = await manager.save(Users, user); const userEnregistre = await manager.save(Users, user);
@ -514,7 +506,6 @@ export class AuthService {
approval_number: dto.numero_agrement, approval_number: dto.numero_agrement,
nir: nirNormalized, nir: nirNormalized,
max_children: dto.capacite_accueil, max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie, biography: dto.biographie,
residence_city: dto.ville ?? undefined, residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
@ -532,28 +523,11 @@ export class AuthService {
throw err; throw err;
} }
const numeroDossier = resultat.user.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return { return {
message: message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id, user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }

View File

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsEmail, IsEmail,
IsNotEmpty, IsNotEmpty,
@ -97,21 +96,17 @@ export class RegisterAMCompletDto {
@IsDateString() @IsDateString()
date_naissance?: string; date_naissance?: string;
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' }) @ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsOptional()
@IsString() @IsString()
@IsNotEmpty({ message: 'La ville de naissance est requise' })
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_ville: string; lieu_naissance_ville?: string;
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' }) @ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsOptional()
@IsString() @IsString()
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_pays: string; lieu_naissance_pays?: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' }) @ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
@IsString() @IsString()
@ -138,17 +133,6 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' }) @Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number; capacite_accueil: number;
@ApiProperty({
example: 2,
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
minimum: 0,
maximum: 10,
})
@IsInt()
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
places_disponibles: number;
// ============================================ // ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel) // ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================ // ============================================

View File

@ -1,17 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /auth/register/am (alignée sur les champs clés de /auth/register/parent). */
export class RegisterAmResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({ enum: StatutUtilisateurType, example: StatutUtilisateurType.EN_ATTENTE })
statut: StatutUtilisateurType;
@ApiProperty({ example: '2026-000015', description: 'Numéro de dossier attribué à linscription' })
numero_dossier: string;
}

View File

@ -62,23 +62,7 @@ export class DossiersService {
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.'); throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
} }
private toDossierAmUserDto(user: { private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto {
id: string;
email: string;
prenom?: string;
nom?: string;
telephone?: string;
adresse?: string;
ville?: string;
code_postal?: string;
profession?: string;
date_naissance?: Date;
lieu_naissance_ville?: string;
lieu_naissance_pays?: string;
photo_url?: string;
consentement_photo?: boolean;
statut: any;
}): DossierAmUserDto {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -90,10 +74,7 @@ export class DossiersService {
code_postal: user.code_postal, code_postal: user.code_postal,
profession: user.profession, profession: user.profession,
date_naissance: user.date_naissance, date_naissance: user.date_naissance,
lieu_naissance_ville: user.lieu_naissance_ville,
lieu_naissance_pays: user.lieu_naissance_pays,
photo_url: user.photo_url, photo_url: user.photo_url,
consentement_photo: user.consentement_photo,
statut: user.statut, statut: user.statut,
}; };
} }

View File

@ -23,14 +23,8 @@ export class DossierAmUserDto {
profession?: string; profession?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
date_naissance?: Date; date_naissance?: Date;
@ApiProperty({ required: false, description: 'Ville de naissance' })
lieu_naissance_ville?: string;
@ApiProperty({ required: false, description: 'Pays de naissance' })
lieu_naissance_pays?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
photo_url?: string; photo_url?: string;
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
consentement_photo?: boolean;
@ApiProperty({ enum: StatutUtilisateurType }) @ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType; statut: StatutUtilisateurType;
} }

View File

@ -55,8 +55,6 @@ CREATE TABLE utilisateurs (
telephone VARCHAR(20), -- Unifié (mobile privilégié) telephone VARCHAR(20), -- Unifié (mobile privilégié)
adresse TEXT, adresse TEXT,
date_naissance DATE, date_naissance DATE,
lieu_naissance_ville VARCHAR(100),
lieu_naissance_pays VARCHAR(100),
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
consentement_photo BOOLEAN DEFAULT false, consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ, date_consentement_photo TIMESTAMPTZ,
@ -127,33 +125,6 @@ CREATE TABLE enfants_parents (
PRIMARY KEY (id_parent, id_enfant) PRIMARY KEY (id_parent, id_enfant)
); );
-- ==========================================================
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
-- ==========================================================
CREATE TABLE dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_dossier VARCHAR(20) NOT NULL,
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
presentation TEXT,
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dossier_famille_numero ON dossier_famille(numero_dossier);
CREATE INDEX idx_dossier_famille_id_parent ON dossier_famille(id_parent);
-- ==========================================================
-- Table : dossier_famille_enfants
-- ==========================================================
CREATE TABLE dossier_famille_enfants (
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_dossier_famille, id_enfant)
);
CREATE INDEX idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
-- ========================================================== -- ==========================================================
-- Table : dossiers -- Table : dossiers
-- ========================================================== -- ==========================================================
@ -405,10 +376,6 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NUL
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
-- ========================================================== -- ==========================================================
-- Seed : Documents légaux génériques v1 -- Seed : Documents légaux génériques v1
-- ========================================================== -- ==========================================================

View File

@ -1,4 +0,0 @@
-- Lieu de naissance (inscription AM, affichage dossier / validation). Nullable pour l'historique.
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL,
ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;

View File

@ -5,7 +5,6 @@
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre). -- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse). -- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
-- - Fatima El Mansouri : née à l'étranger → NIR 99. -- - Fatima El Mansouri : née à l'étranger → NIR 99.
-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
-- À exécuter après BDD.sql (init DB) -- À exécuter après BDD.sql (init DB)
-- ============================================================ -- ============================================================

View File

@ -1,44 +0,0 @@
# TEMP — Alignement front / API (inscription AM & validation gestionnaire)
> **Fichier temporaire** : à supprimer ou renommer une fois le front livré.
Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front na été faite dans le chantier backend associé.
## 1. `POST /auth/register/am` — lieu de naissance obligatoire
- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100).
- Réponses **400** si manquants ou invalides (messages class-validator).
- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par lAPI.
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
Sous `dossier.user`, lAPI peut inclure :
| Clé JSON | Description |
|----------|-------------|
| `date_naissance` | Date (si renseignée à linscription) |
| `lieu_naissance_ville` | Ville de naissance |
| `lieu_naissance_pays` | Pays de naissance |
| `consentement_photo` | Booléen (exposé dans `dossier.user`) |
À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc.
**Action front** :
- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`.
- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest).
## 3. `ValidationAmWizard` (admin)
Afficher pour cohérence avec le formulaire dinscription :
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
- **Informations professionnelles** : disponibilité, années dexpérience, spécialité (afficher « » si `null`).
## 4. `place_disponible` à linscription
- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public.
---
*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.*

View File

@ -16,8 +16,6 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
## 👥 Utilisateurs de test ## 👥 Utilisateurs de test
**Source de vérité (identités, NIR, agréments, adresses)** : `database/seed/03_seed_test_data.sql`, complété par `utilisateurs-test.csv` et ce README. Les scripts Node `tests/scripts/register-am-*-test.mjs` reprennent les mêmes valeurs.
### 1. Administrateur ### 1. Administrateur
| Nom | Prénom | Email | Téléphone | Mobile | | Nom | Prénom | Email | Téléphone | Mobile |
@ -52,13 +50,6 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Spécialité** : Bébés 0-18 mois **Spécialité** : Bébés 0-18 mois
**Agrément** : 4 enfants **Agrément** : 4 enfants
**Places disponibles** : 2 **Places disponibles** : 2
**Date de naissance** : 1980-06-08
**Lieu de naissance (inscription)** : Ajaccio (Corse, France) — NIR de test avec code département **2A**
**NIR (fictif, clé valide)** : `280062A00100191`
**N° d'agrément** : `AGR-2019-095001`
**Date d'agrément** : 2019-09-01
**Adresse** : 25 Rue de la République, 95870 Bezons
**Script d'inscription** : `tests/scripts/register-am-dubois-test.mjs`
#### Fatima EL MANSOURI #### Fatima EL MANSOURI
@ -70,13 +61,6 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Spécialité** : 1-3 ans **Spécialité** : 1-3 ans
**Agrément** : 3 enfants **Agrément** : 3 enfants
**Places disponibles** : 1 **Places disponibles** : 1
**Date de naissance** : 1975-11-12
**Lieu de naissance (inscription)** : exemple Casablanca (Maroc) — NIR de test avec code **99** (naissance à l'étranger)
**NIR (fictif, clé valide)** : `275119900100102`
**N° d'agrément** : `AGR-2017-095002`
**Date d'agrément** : 2017-06-15
**Adresse** : 17 Boulevard Aristide Briand, 95870 Bezons
**Script d'inscription** : `tests/scripts/register-am-mansouri-test.mjs`
--- ---
@ -176,14 +160,20 @@ POST /api/v1/gestionnaires
} }
``` ```
#### Scénario 2 : Inscription assistante maternelle (complète) #### Scénario 2 : Inscription assistante maternelle
Inscription en un flux : `POST /api/v1/auth/register/am` (identité, NIR, agrément, photo optionnelle, CGU). Voir le DTO backend `RegisterAMCompletDto`. ```typescript
// Marie DUBOIS s'inscrit
```bash POST /api/v1/auth/register
# Jeux alignés sur le seed (mêmes NIR / dates / agréments que 03_seed_test_data.sql) : {
node tests/scripts/register-am-dubois-test.mjs [BASE_URL] "email": "marie.dubois@ptits-pas.fr",
node tests/scripts/register-am-mansouri-test.mjs [BASE_URL] "password": "Test1234!",
"prenom": "Marie",
"nom": "DUBOIS",
"telephone": "01 39 98 67 89",
"mobile": "06 96 34 56 78",
"role": "assistante_maternelle"
}
``` ```
#### Scénario 3 : Inscription parent #### Scénario 3 : Inscription parent

View File

@ -177,12 +177,10 @@
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="info-title">Profession</div> <div class="info-title">Profession</div>
<p><strong>Agrément :</strong> 4 enfants max (n° AGR-2019-095001, depuis le 01/09/2019)</p> <p><strong>Agrément :</strong> 4 enfants max</p>
<p><strong>Expérience :</strong> 12 ans</p> <p><strong>Expérience :</strong> 12 ans</p>
<p><strong>Spécialité :</strong> Bébés 0-18 mois</p> <p><strong>Spécialité :</strong> Bébés 0-18 mois</p>
<p><strong>Places disponibles :</strong> 2</p> <p><strong>Places disponibles :</strong> 2</p>
<p><strong>NIR (test, fictif) :</strong> 280062A00100191</p>
<p><strong>Lieu de naissance :</strong> Ajaccio (Corse)</p>
</div> </div>
</div> </div>
</div> </div>
@ -209,12 +207,10 @@
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="info-title">Profession</div> <div class="info-title">Profession</div>
<p><strong>Agrément :</strong> 3 enfants max (n° AGR-2017-095002, depuis le 15/06/2017)</p> <p><strong>Agrément :</strong> 3 enfants max</p>
<p><strong>Expérience :</strong> 15 ans</p> <p><strong>Expérience :</strong> 15 ans</p>
<p><strong>Spécialité :</strong> Enfants 1-3 ans</p> <p><strong>Spécialité :</strong> Enfants 1-3 ans</p>
<p><strong>Places disponibles :</strong> 1</p> <p><strong>Places disponibles :</strong> 1</p>
<p><strong>NIR (test, fictif) :</strong> 275119900100102</p>
<p><strong>Lieu de naissance :</strong> à l'étranger (ex. Casablanca, Maroc — inscription)</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
const int kAmCapaciteAccueilMax = 4;
class AmRegistrationData extends ChangeNotifier { class AmRegistrationData extends ChangeNotifier {
// Step 1: Identity Info // Step 1: Identity Info
String firstName = ''; String firstName = '';
@ -27,11 +24,7 @@ class AmRegistrationData extends ChangeNotifier {
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry // String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
String nir = ''; // Numéro de Sécurité Sociale String nir = ''; // Numéro de Sécurité Sociale
String agrementNumber = ''; // Numéro d'agrément String agrementNumber = ''; // Numéro d'agrément
/// Date d'obtention de l'agrément obligatoire (API `date_agrement`).
DateTime? agreementDate;
int? capacity; // Number of children the AM can look after int? capacity; // Number of children the AM can look after
/// Places libres actuellement (0 valeur capacité) API `places_disponibles`.
int? placesAvailable;
// Step 3: Presentation & CGU // Step 3: Presentation & CGU
String presentationText = ''; String presentationText = '';
@ -75,9 +68,7 @@ class AmRegistrationData extends ChangeNotifier {
// String? placeOfBirth, // Remplacé // String? placeOfBirth, // Remplacé
String? nir, String? nir,
String? agrementNumber, String? agrementNumber,
DateTime? agreementDate,
int? capacity, int? capacity,
int? placesAvailable,
}) { }) {
this.photoPath = photoPath; this.photoPath = photoPath;
this.photoBytes = photoBytes; this.photoBytes = photoBytes;
@ -89,9 +80,7 @@ class AmRegistrationData extends ChangeNotifier {
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé // this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
this.nir = nir ?? this.nir; this.nir = nir ?? this.nir;
this.agrementNumber = agrementNumber ?? this.agrementNumber; this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity; this.capacity = capacity ?? this.capacity;
this.placesAvailable = placesAvailable ?? this.placesAvailable;
notifyListeners(); notifyListeners();
} }
@ -106,11 +95,11 @@ class AmRegistrationData extends ChangeNotifier {
// --- Getters for validation or display --- // --- Getters for validation or display ---
bool get isStep1Complete => bool get isStep1Complete =>
firstName.trim().length >= 2 && firstName.isNotEmpty &&
lastName.trim().length >= 2 && lastName.isNotEmpty &&
streetAddress.isNotEmpty && streetAddress.isNotEmpty && // Modifié
postalCode.isNotEmpty && postalCode.isNotEmpty && // Nouveau
city.isNotEmpty && city.isNotEmpty && // Nouveau
phone.isNotEmpty && phone.isNotEmpty &&
email.isNotEmpty; email.isNotEmpty;
// password n'est pas requis à l'inscription (défini après validation par lien email) // password n'est pas requis à l'inscription (défini après validation par lien email)
@ -123,20 +112,13 @@ class AmRegistrationData extends ChangeNotifier {
!photoPath!.startsWith('assets/')); !photoPath!.startsWith('assets/'));
bool get isStep2Complete => bool get isStep2Complete =>
_hasUserPhoto && (_hasUserPhoto ? photoConsent == true : true) &&
photoConsent == true &&
dateOfBirth != null && dateOfBirth != null &&
birthCity.trim().length >= 2 && birthCity.isNotEmpty &&
birthCountry.trim().length >= 2 && birthCountry.isNotEmpty &&
nir.isNotEmpty && nir.isNotEmpty && // Basic check, could add validation
agrementNumber.isNotEmpty && agrementNumber.isNotEmpty &&
agreementDate != null && capacity != null && capacity! > 0;
capacity != null &&
capacity! >= 1 &&
capacity! <= kAmCapaciteAccueilMax &&
placesAvailable != null &&
placesAvailable! >= 0 &&
placesAvailable! <= capacity!;
bool get isStep3Complete => bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire) // presentationText is optional as per CDC (message au gestionnaire)
@ -153,7 +135,7 @@ class AmRegistrationData extends ChangeNotifier {
'phone: $phone, email: $email, ' 'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié // 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, ' 'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, ' 'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, ' 'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)'; 'presentationText: $presentationText, cguAccepted: $cguAccepted)';
} }

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
@ -212,6 +213,14 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) { factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl']; final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto); final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} '
'prénom=${json['first_name'] ?? json['prenom']} | '
'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → '
'résolu=${resolvedPhoto ?? ""}',
);
}
return EnfantDossier( return EnfantDossier(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),

View File

@ -16,9 +16,6 @@ class AppUser {
final String? relaisId; final String? relaisId;
final String? relaisNom; final String? relaisNom;
final String? numeroDossier; final String? numeroDossier;
final String? dateNaissance;
final String? lieuNaissanceVille;
final String? lieuNaissancePays;
AppUser({ AppUser({
required this.id, required this.id,
@ -38,9 +35,6 @@ class AppUser {
this.relaisId, this.relaisId,
this.relaisNom, this.relaisNom,
this.numeroDossier, this.numeroDossier,
this.dateNaissance,
this.lieuNaissanceVille,
this.lieuNaissancePays,
}); });
static String _str(dynamic v) { static String _str(dynamic v) {
@ -84,24 +78,9 @@ class AppUser {
?.toString(), ?.toString(),
relaisNom: relaisMap['nom']?.toString(), relaisNom: relaisMap['nom']?.toString(),
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null, numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
dateNaissance: _optionalDateString(json['date_naissance']),
lieuNaissanceVille: json['lieu_naissance_ville'] is String
? json['lieu_naissance_ville'] as String
: null,
lieuNaissancePays: json['lieu_naissance_pays'] is String
? json['lieu_naissance_pays'] as String
: null,
); );
} }
static String? _optionalDateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
if (v is DateTime) return v.toIso8601String().split('T').first;
final s = v.toString();
return s.isEmpty ? null : s;
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -121,9 +100,6 @@ class AppUser {
'relais_id': relaisId, 'relais_id': relaisId,
'relais_nom': relaisNom, 'relais_nom': relaisNom,
'numero_dossier': numeroDossier, 'numero_dossier': numeroDossier,
'date_naissance': dateNaissance,
'lieu_naissance_ville': lieuNaissanceVille,
'lieu_naissance_pays': lieuNaissancePays,
}; };
} }

View File

@ -28,7 +28,6 @@ class AmRegisterStep1Screen extends StatelessWidget {
title: 'Vos informations personnelles', title: 'Vos informations personnelles',
cardColor: CardColorHorizontal.blue, cardColor: CardColorHorizontal.blue,
initialData: initialData, initialData: initialData,
minPersonNameLength: 2,
previousRoute: '/register-choice', previousRoute: '/register-choice',
onSubmit: (data, {hasSecondPerson, sameAddress}) { onSubmit: (data, {hasSecondPerson, sameAddress}) {
registrationData.updateIdentityInfo( registrationData.updateIdentityInfo(

View File

@ -23,9 +23,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: registrationData.birthCountry, birthCountry: registrationData.birthCountry,
nir: registrationData.nir, nir: registrationData.nir,
agrementNumber: registrationData.agrementNumber, agrementNumber: registrationData.agrementNumber,
agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity, capacity: registrationData.capacity,
placesAvailable: registrationData.placesAvailable,
); );
return ProfessionalInfoFormScreen( return ProfessionalInfoFormScreen(
@ -45,9 +43,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: data.birthCountry, birthCountry: data.birthCountry,
nir: data.nir, nir: data.nir,
agrementNumber: data.agrementNumber, agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity, capacity: data.capacity,
placesAvailable: data.placesAvailable,
); );
context.go('/am-register-step3'); context.go('/am-register-step3');
}, },

View File

@ -27,19 +27,6 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async { Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!registrationData.isRegistrationComplete) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Le dossier est incomplet. Vérifiez chaque étape (photo, consentement, lieux de naissance, etc.).',
style: GoogleFonts.merienda(fontSize: 14),
),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
try { try {
await AuthService.registerAM(registrationData); await AuthService.registerAM(registrationData);
@ -217,9 +204,7 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
birthCountry: data.birthCountry, birthCountry: data.birthCountry,
nir: data.nir, nir: data.nir,
agrementNumber: data.agrementNumber, agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity, capacity: data.capacity,
placesAvailable: data.placesAvailable,
photoConsent: data.photoConsent, photoConsent: data.photoConsent,
), ),
onSubmit: (d) {}, onSubmit: (d) {},

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/config/env.dart'; import 'package:p_tits_pas/config/env.dart';
class ApiConfig { class ApiConfig {
@ -20,14 +21,26 @@ class ApiConfig {
/// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose souvent que `/api`. /// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose souvent que `/api`.
static String absoluteMediaUrl(String? pathOrUrl) { static String absoluteMediaUrl(String? pathOrUrl) {
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) { if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")');
}
return ''; return '';
} }
final u = pathOrUrl.trim(); final u = pathOrUrl.trim();
if (u.startsWith('http://') || u.startsWith('https://')) { if (u.startsWith('http://') || u.startsWith('https://')) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u');
}
return u; return u;
} }
final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
return u.startsWith('/') ? '$base$u' : '$base/$u'; final out = u.startsWith('/') ? '$base$u' : '$base/$u';
if (kDebugMode) {
debugPrint(
'[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"',
);
}
return out;
} }
// Auth endpoints // Auth endpoints

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -23,17 +22,6 @@ class AuthService {
return name.isEmpty ? null : name; return name.isEmpty ? null : name;
} }
static String _imageMimeForBytes(Uint8List bytes) {
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
return 'image/jpeg';
}
/// Connexion de l'utilisateur /// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async { static Future<AppUser> login(String email, String password) async {
@ -162,25 +150,11 @@ class AuthService {
/// Inscription AM complète (POST /auth/register/am). /// Inscription AM complète (POST /auth/register/am).
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login. /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future<void> registerAM(AmRegistrationData data) async { static Future<void> registerAM(AmRegistrationData data) async {
if (data.agreementDate == null) {
throw Exception('La date d\'obtention de l\'agrément est requise.');
}
if (data.placesAvailable == null) {
throw Exception('Le nombre de places disponibles est requis.');
}
final lieuVille = data.birthCity.trim();
final lieuPays = data.birthCountry.trim();
if (lieuVille.length < 2 || lieuPays.length < 2) {
throw Exception(
'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
);
}
String? photoBase64; String? photoBase64;
String? photoFilename; String? photoFilename;
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
final mime = _imageMimeForBytes(data.photoBytes!); photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}';
photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
final fn = (data.photoFilename ?? '').trim(); final fn = (data.photoFilename ?? '').trim();
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg'; photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
} else if (data.photoPath != null && } else if (data.photoPath != null &&
@ -191,8 +165,7 @@ class AuthService {
final file = File(data.photoPath!); final file = File(data.photoPath!);
if (await file.exists()) { if (await file.exists()) {
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
final mime = _imageMimeForBytes(bytes); photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
photoFilename = photoFilename =
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg'; _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
} }
@ -200,10 +173,6 @@ class AuthService {
} }
} }
if (photoBase64 == null || photoBase64.isEmpty) {
throw Exception('Une photo de profil est requise pour finaliser linscription.');
}
final body = <String, dynamic>{ final body = <String, dynamic>{
'email': data.email, 'email': data.email,
'prenom': data.firstName, 'prenom': data.firstName,
@ -212,20 +181,17 @@ class AuthService {
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null, 'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null, 'ville': data.city.isNotEmpty ? data.city : null,
'photo_base64': photoBase64, if (photoBase64 != null) 'photo_base64': photoBase64,
'photo_filename': photoFilename ?? 'photo_am.jpg', if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent, 'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null 'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' ? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null, : null,
'lieu_naissance_ville': lieuVille, 'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
'lieu_naissance_pays': lieuPays, 'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
'nir': normalizeNir(data.nir), 'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber, 'numero_agrement': data.agrementNumber,
'date_agrement':
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1, 'capacite_accueil': data.capacity ?? 1,
'places_disponibles': data.placesAvailable!,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null, 'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted, 'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted, 'acceptation_privacy': data.cguAccepted,

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -93,6 +94,9 @@ class UserService {
static Future<DossierUnifie> getDossier(String numeroDossier) async { static Future<DossierUnifie> getDossier(String numeroDossier) async {
final encoded = Uri.encodeComponent(numeroDossier); final encoded = Uri.encodeComponent(numeroDossier);
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'); final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
if (kDebugMode) {
debugPrint('[PetitsPas/dossier] GET $uri');
}
final response = await http.get( final response = await http.get(
uri, uri,
headers: await _headers(), headers: await _headers(),
@ -115,6 +119,19 @@ class UserService {
throw FormatException('Réponse invalide'); throw FormatException('Réponse invalide');
} }
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded)); final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] réponse OK type=${dossier.type} | '
'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}',
);
if (dossier.isFamily) {
final f = dossier.asFamily;
debugPrint(
'[PetitsPas/dossier] famille ${f.numeroDossier} | '
'${f.enfants.length} enfant(s)',
);
}
}
return dossier; return dossier;
} catch (e) { } catch (e) {
if (e is FormatException) rethrow; if (e is FormatException) rethrow;

View File

@ -1,11 +0,0 @@
import 'package:intl/intl.dart';
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
return DateFormat('dd/MM/yyyy').format(DateTime.parse(s.trim()));
} catch (_) {
return s.trim();
}
}

View File

@ -57,22 +57,20 @@ String formatNir(String raw) {
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
} }
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse. /// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
bool _isFormatValid(String raw) { bool _isFormatValid(String raw) {
final r = raw.toUpperCase(); if (raw.length != 15) return false;
if (r.length != 15) return false; final dept = raw.substring(5, 7);
return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r); final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
} }
/// CINSEE : 97 - (NIR13 mod 97). Corse : 2A19, 2B20 (aligné backend). /// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A19, 2B18.
int _controlKey(String raw13) { int _controlKey(String raw13) {
String n; String n = raw13;
if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') { if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
n = '${raw13.substring(0, 5)}19${raw13.substring(7)}'; n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
} else if (raw13.length >= 7 && raw13.substring(5, 7) == '2B') {
n = '${raw13.substring(0, 5)}20${raw13.substring(7)}';
} else {
n = '${raw13.substring(0, 5)}${raw13.substring(5, 7)}${raw13.substring(7)}';
} }
final big = int.tryParse(n); final big = int.tryParse(n);
if (big == null) return -1; if (big == null) return -1;

View File

@ -5,8 +5,7 @@ import 'admin_detail_modal.dart';
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2. /// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5). /// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
class ValidationDetailSection extends StatelessWidget { class ValidationDetailSection extends StatelessWidget {
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM). final String title;
final String? title;
final List<AdminDetailField> fields; final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
@ -17,7 +16,7 @@ class ValidationDetailSection extends StatelessWidget {
const ValidationDetailSection({ const ValidationDetailSection({
super.key, super.key,
this.title, required this.title,
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
@ -61,14 +60,12 @@ class ValidationDetailSection extends StatelessWidget {
)); ));
} }
} }
final showTitle = title != null && title!.trim().isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (showTitle) ...[
Text( Text(
title!.trim(), title,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -76,7 +73,6 @@ class ValidationDetailSection extends StatelessWidget {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
],
...rows, ...rows,
], ],
); );

View File

@ -155,6 +155,70 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
); );
} }
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
Widget _buildPendingRow({
required IconData icon,
String? title,
Widget? titleWidget,
String? subtitle,
TextStyle? subtitleStyle,
required VoidCallback onOpen,
}) {
assert(title != null || titleWidget != null);
final titleChild = titleWidget ??
Text(
title!,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
);
final subStyle = subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
titleChild,
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: subStyle,
),
],
],
),
),
ElevatedButton.icon(
onPressed: onOpen,
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Ouvrir'),
),
],
),
),
);
}
/// Sous-titre AM : `email - date tél. CP ville` (plan affichage lignes À valider). /// Sous-titre AM : `email - date tél. CP ville` (plan affichage lignes À valider).
String _amSubtitleLine(AppUser user) { String _amSubtitleLine(AppUser user) {
@ -180,9 +244,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
final numDossier = user.numeroDossier ?? ''; final numDossier = user.numeroDossier ?? '';
final nameBold = final nameBold =
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : ''); user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '');
return _PendingValidationRow( return _buildPendingRow(
icon: Icons.person_outline, icon: Icons.person_outline,
title: Text.rich( titleWidget: Text.rich(
TextSpan( TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87), style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [ children: [
@ -256,9 +320,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
Widget _buildFamilyCard(PendingFamily family) { Widget _buildFamilyCard(PendingFamily family) {
final numDossier = family.numeroDossier ?? ''; final numDossier = family.numeroDossier ?? '';
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille'; final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
return _PendingValidationRow( return _buildPendingRow(
icon: Icons.family_restroom_outlined, icon: Icons.family_restroom_outlined,
title: Text.rich( titleWidget: Text.rich(
TextSpan( TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87), style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [ children: [
@ -288,111 +352,3 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
} }
} }
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
class _PendingValidationRow extends StatefulWidget {
final IconData icon;
final Widget title;
final String? subtitle;
final TextStyle? subtitleStyle;
final VoidCallback onOpen;
const _PendingValidationRow({
required this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
required this.onOpen,
});
@override
State<_PendingValidationRow> createState() => _PendingValidationRowState();
}
class _PendingValidationRowState extends State<_PendingValidationRow> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final subStyle = widget.subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
widget.title,
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: subStyle,
),
],
],
),
),
SizedBox(
width: 52,
child: Center(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
padding: const EdgeInsets.all(0),
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
child: IconButton(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
iconSize: 34,
tooltip: 'Ouvrir',
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
@ -75,28 +74,16 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
AdminDetailField(label: 'Ville', value: _v(u.ville)), AdminDetailField(label: 'Ville', value: _v(u.ville)),
]; ];
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. /// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
List<AdminDetailField> _photoProFields(DossierAM d) { List<AdminDetailField> _proFields(DossierAM d) => [
final u = d.user;
return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance),
),
AdminDetailField(
label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille),
),
AdminDetailField(
label: 'Pays de naissance',
value: _v(u.lieuNaissancePays),
),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField( AdminDetailField(
label: 'Date dagrément', label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement), value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
? d.dateAgrement!.trim()
: '',
), ),
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField( AdminDetailField(
label: 'Capacité max (enfants)', label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '', value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
@ -107,8 +94,9 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
? d.placesDisponibles.toString() ? d.placesDisponibles.toString()
: '', : '',
), ),
AdminDetailField(
label: 'Ville de résidence', value: _v(d.villeResidence)),
]; ];
}
static const List<int> _personalRowLayout = [2, 2, 1, 2]; static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = { static const Map<int, List<int>> _personalRowFlex = {
@ -131,6 +119,15 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text(
'Photo de profil',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@ -267,7 +264,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth), constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: ValidationDetailSection( child: ValidationDetailSection(
title: 'Identité et coordonnées', title: 'Informations personnelles',
fields: _personalFields(u), fields: _personalFields(u),
rowLayout: _personalRowLayout, rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex, rowFlex: _personalRowFlex,
@ -283,7 +280,8 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; final maxRowH = c.maxHeight;
const photoHeaderH = 0.0; // Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
const photoHeaderH = 52.0;
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
final idealPhotoW = final idealPhotoW =
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
@ -309,9 +307,14 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
constraints: BoxConstraints( constraints: BoxConstraints(
minWidth: constraints.maxWidth), minWidth: constraints.maxWidth),
child: ValidationDetailSection( child: ValidationDetailSection(
title: 'Dossier professionnel', title: 'Informations professionnelles',
fields: _photoProFields(d), fields: _proFields(d),
rowLayout: const [2, 2, 2, 2], rowLayout: const [
2,
1,
2,
1
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
), ),
), ),
); );
@ -330,13 +333,12 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( Text(
'Présentation', 'Présentation',
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87, color: Colors.black87),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(

View File

@ -1,8 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
@ -105,8 +106,15 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
/// Date de naissance en jour/mois/année (dd/MM/yyyy). /// Date de naissance en jour/mois/année (dd/MM/yyyy).
static String _formatBirthDate(String? s) => static String _formatBirthDate(String? s) {
formatIsoDateFr(s, ifEmpty: 'Non défini'); if (s == null || s.trim().isEmpty) return 'Non défini';
try {
final d = DateTime.parse(s.trim());
return DateFormat('dd/MM/yyyy').format(d);
} catch (_) {
return s.trim();
}
}
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [ List<AdminDetailField> _parentFields(ParentDossier p) => [
@ -336,6 +344,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) { Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl); final photoUrl = _fullPhotoUrl(e.photoUrl);
if (kDebugMode) {
debugPrint(
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
'photoUrl modèle=${e.photoUrl ?? ""} | url affichée=${photoUrl.isEmpty ? "" : photoUrl}',
);
}
final columnStatusLabel = _enfantColumnStatusLabel(e); final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius), borderRadius: BorderRadius.circular(_enfantCardRadius),
@ -555,7 +569,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
'Présentation', 'Présentation / Motivation',
style: TextStyle( style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
), ),

View File

@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'registration_photo_slot.dart'; import 'hover_relief_widget.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart';
@ -16,12 +16,13 @@ const String _photoConsentTooltip =
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n' 'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.'; '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) { bool _hasChildPhoto(ChildData c) {
return registrationPhotoSlotHasImage( final b = c.imageBytes;
imageBytes: c.imageBytes, if (b != null && b.isNotEmpty) return true;
imageFile: c.imageFile, return c.imageFile != null;
imagePathOrAsset: null,
);
} }
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
@ -193,6 +194,11 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200 ? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow);
final Color hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow);
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile final double editableCardWidth = config.isMobile
? double.infinity ? double.infinity
@ -216,15 +222,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
RegistrationPhotoSlot( _buildEditablePhotoArea(
side: photoSide, context: context,
config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
imageBytes: widget.childData.imageBytes, photoSide: photoSide,
imageFile: widget.childData.imageFile, childData: widget.childData,
onTapPick: !config.isReadonly ? widget.onPickImage : null, initialPhotoShadow: initialPhotoShadow,
onClear: !config.isReadonly ? widget.onClearImage : null, hoverPhotoShadow: hoverPhotoShadow,
baseShadowColor: baseCardColorForShadow,
isMobile: config.isMobile,
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow( _buildPhotoConsentRow(
@ -316,6 +321,96 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor).
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canInteract ? widget.onPickImage : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
// Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
width: photoSide,
height: photoSide,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && canInteract)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onClearImage,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: config.isMobile ? 26 : 30,
height: config.isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal) /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
@ -42,6 +43,12 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url); final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
_headersFuture = _headersFuture =
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders(); isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] préparation chargement url=${widget.url} | '
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
);
}
} }
static Future<Map<String, String>?> _loadHeaders() async { static Future<Map<String, String>?> _loadHeaders() async {
@ -52,6 +59,11 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
ImageErrorWidgetBuilder _wrapErrorBuilder() { ImageErrorWidgetBuilder _wrapErrorBuilder() {
return (BuildContext context, Object error, StackTrace? stackTrace) { return (BuildContext context, Object error, StackTrace? stackTrace) {
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error',
);
}
final inner = widget.errorBuilder; final inner = widget.errorBuilder;
if (inner != null) { if (inner != null) {
return inner(context, error, stackTrace); return inner(context, error, stackTrace);
@ -100,6 +112,9 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
); );
} }
final headers = snapshot.data; final headers = snapshot.data;
if (kDebugMode && headers != null) {
debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)');
}
return Image.network( return Image.network(
widget.url, widget.url,
width: widget.width, width: widget.width,

View File

@ -27,14 +27,10 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon; final IconData? suffixIcon;
final double labelFontSize; final double labelFontSize;
final double inputFontSize; final double inputFontSize;
/// Espace vertical entre le libellé et la zone de saisie.
final double labelFieldSpacing;
final bool showLabel; final bool showLabel;
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
/// Souvent appelé par la touche « suivant » du clavier (flèche), [onFieldSubmitted] ne lest pas.
final VoidCallback? onEditingComplete;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool autocorrect; final bool autocorrect;
final bool enableSuggestions; final bool enableSuggestions;
@ -60,11 +56,9 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon, this.suffixIcon,
this.labelFontSize = 18.0, this.labelFontSize = 18.0,
this.inputFontSize = 18.0, this.inputFontSize = 18.0,
this.labelFieldSpacing = 6.0,
this.autofillHints, this.autofillHints,
this.textInputAction, this.textInputAction,
this.onFieldSubmitted, this.onFieldSubmitted,
this.onEditingComplete,
this.inputFormatters, this.inputFormatters,
this.autocorrect = true, this.autocorrect = true,
this.enableSuggestions = true, this.enableSuggestions = true,
@ -111,7 +105,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
SizedBox(height: widget.labelFieldSpacing), const SizedBox(height: 6),
], ],
// Pas de hauteur fixe sur le TextFormField : le message derreur du // Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait. // validateur saffiche en dessous ; un SizedBox fixe le masquait.
@ -147,7 +141,6 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
autofillHints: widget.autofillHints, autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted, onFieldSubmitted: widget.onFieldSubmitted,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled, enabled: widget.enabled,
readOnly: widget.readOnly, readOnly: widget.readOnly,
onTap: widget.onTap, onTap: widget.onTap,

View File

@ -8,9 +8,6 @@ import 'custom_app_text_field.dart';
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission. /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget { class NirTextField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
/// Si non null, impose lordre Tab parmi les descendants (ex. après « Pays de naissance »).
final double? focusTraversalOrder;
final String labelText; final String labelText;
final String hintText; final String hintText;
final String? Function(String?)? validator; final String? Function(String?)? validator;
@ -21,15 +18,12 @@ class NirTextField extends StatelessWidget {
final bool enabled; final bool enabled;
final bool readOnly; final bool readOnly;
final CustomAppTextFieldStyle style; final CustomAppTextFieldStyle style;
final double labelFieldSpacing;
const NirTextField({ const NirTextField({
super.key, super.key,
required this.controller, required this.controller,
this.focusNode,
this.focusTraversalOrder,
this.labelText = 'N° Sécurité Sociale (NIR)', this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '13 chiffres + clé', this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)',
this.validator, this.validator,
this.fieldWidth = double.infinity, this.fieldWidth = double.infinity,
this.fieldHeight = 53.0, this.fieldHeight = 53.0,
@ -38,14 +32,12 @@ class NirTextField extends StatelessWidget {
this.enabled = true, this.enabled = true,
this.readOnly = false, this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige, this.style = CustomAppTextFieldStyle.beige,
this.labelFieldSpacing = 6.0,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final field = CustomAppTextField( return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: labelText, labelText: labelText,
hintText: hintText, hintText: hintText,
fieldWidth: fieldWidth, fieldWidth: fieldWidth,
@ -58,15 +50,6 @@ class NirTextField extends StatelessWidget {
enabled: enabled, enabled: enabled,
readOnly: readOnly, readOnly: readOnly,
style: style, style: style,
labelFieldSpacing: labelFieldSpacing,
);
final o = focusTraversalOrder;
if (o != null) {
return FocusTraversalOrder(
order: NumericFocusOrder(o),
child: field,
); );
} }
return field;
}
} }

View File

@ -55,8 +55,6 @@ class PersonalInfoFormScreen extends StatefulWidget {
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse" final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre) final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché) final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
/// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
final int? minPersonNameLength;
const PersonalInfoFormScreen({ const PersonalInfoFormScreen({
super.key, super.key,
@ -74,7 +72,6 @@ class PersonalInfoFormScreen extends StatefulWidget {
this.referenceAddressData, this.referenceAddressData,
this.embedContentOnly = false, this.embedContentOnly = false,
this.onEdit, this.onEdit,
this.minPersonNameLength,
}); });
@override @override
@ -217,16 +214,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
return validateEmail(value, allowEmpty: true); return validateEmail(value, allowEmpty: true);
} }
/// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
String? _validatePersonNameWithMinLength(String? value) {
final min = widget.minPersonNameLength;
if (min == null) return null;
final t = (value ?? '').trim();
if (t.isEmpty) return 'Ce champ est obligatoire';
if (t.length < min) return 'Au moins $min caractères';
return null;
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé. /// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) { String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) { if (widget.showSecondPersonToggle && !_fieldsEnabled) {
@ -850,9 +837,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -865,9 +849,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
], ],
@ -1125,9 +1106,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1140,9 +1118,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -7,18 +7,15 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:io'; import 'dart:io';
import '../models/am_registration_data.dart' show kAmCapaciteAccueilMax;
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import '../utils/nir_utils.dart'; import '../utils/nir_utils.dart';
import '../utils/name_format_utils.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'nir_text_field.dart'; import 'nir_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart'; import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart'; import 'hover_relief_widget.dart';
import 'custom_navigation_button.dart'; import 'custom_navigation_button.dart';
import 'registration_photo_slot.dart';
/// Données pour le formulaire d'informations professionnelles /// Données pour le formulaire d'informations professionnelles
class ProfessionalInfoData { class ProfessionalInfoData {
@ -32,10 +29,7 @@ class ProfessionalInfoData {
final String birthCountry; final String birthCountry;
final String nir; final String nir;
final String agrementNumber; final String agrementNumber;
/// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
final DateTime? agreementDate;
final int? capacity; final int? capacity;
final int? placesAvailable;
ProfessionalInfoData({ ProfessionalInfoData({
this.photoPath, this.photoPath,
@ -48,9 +42,7 @@ class ProfessionalInfoData {
this.birthCountry = '', this.birthCountry = '',
this.nir = '', this.nir = '',
this.agrementNumber = '', this.agrementNumber = '',
this.agreementDate,
this.capacity, this.capacity,
this.placesAvailable,
}); });
} }
@ -90,34 +82,14 @@ class ProfessionalInfoFormScreen extends StatefulWidget {
class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> { class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Ordre Tab explicite : sans cela, la zone photo (InkWell) peut sinsérer entre pays et NIR.
static const double _kTabPhotoPick = 1;
static const double _kTabDateBirth = 2;
static const double _kTabBirthCity = 3;
static const double _kTabBirthCountry = 4;
static const double _kTabNir = 5;
static const double _kTabAgrement = 6;
static const double _kTabAgreementDate = 7;
static const double _kTabCapacity = 8;
static const double _kTabPlaces = 9;
static const double _kTabChevronNext = 10;
static const double _kTabChevronPrev = 11;
final _dateOfBirthController = TextEditingController(); final _dateOfBirthController = TextEditingController();
final _birthCityController = TextEditingController(); final _birthCityController = TextEditingController();
final _birthCountryController = TextEditingController(); final _birthCountryController = TextEditingController();
final _nirController = TextEditingController(); final _nirController = TextEditingController();
final _agrementController = TextEditingController(); final _agrementController = TextEditingController();
final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController(); final _capacityController = TextEditingController();
final _placesAvailableController = TextEditingController();
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
FocusNode? _nirFocus;
DateTime? _selectedDate; DateTime? _selectedDate;
DateTime? _selectedAgreementDate;
String? _photoPathFramework; String? _photoPathFramework;
File? _photoFile; File? _photoFile;
Uint8List? _photoBytes; Uint8List? _photoBytes;
@ -146,89 +118,26 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final nirRaw = nirToRaw(data.nir); final nirRaw = nirToRaw(data.nir);
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
_agrementController.text = data.agrementNumber; _agrementController.text = data.agrementNumber;
_selectedAgreementDate = data.agreementDate;
_agreementDateController.text = data.agreementDate != null
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
: '';
_capacityController.text = data.capacity?.toString() ?? ''; _capacityController.text = data.capacity?.toString() ?? '';
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
_photoPathFramework = data.photoPath; _photoPathFramework = data.photoPath;
_photoFile = data.photoFile; _photoFile = data.photoFile;
_photoBytes = data.photoBytes; _photoBytes = data.photoBytes;
_photoFilename = data.photoFilename; _photoFilename = data.photoFilename;
_photoConsent = data.photoConsent; _photoConsent = data.photoConsent;
} }
if (widget.mode == DisplayMode.editable) {
_birthCityFocus = FocusNode();
_birthCountryFocus = FocusNode();
_nirFocus = FocusNode();
_birthCityFocus!.addListener(_onBirthCityFocusChange);
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
}
}
void _onBirthCityFocusChange() {
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
_applyPlaceNameFormat(_birthCityController);
}
void _onBirthCountryFocusChange() {
if (_birthCountryFocus == null || _birthCountryFocus!.hasFocus) return;
_applyPlaceNameFormat(_birthCountryController);
}
void _applyPlaceNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) return;
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
} }
@override @override
void dispose() { void dispose() {
_birthCityFocus?.removeListener(_onBirthCityFocusChange);
_birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
_birthCityFocus?.dispose();
_birthCountryFocus?.dispose();
_nirFocus?.dispose();
_dateOfBirthController.dispose(); _dateOfBirthController.dispose();
_birthCityController.dispose(); _birthCityController.dispose();
_birthCountryController.dispose(); _birthCountryController.dispose();
_nirController.dispose(); _nirController.dispose();
_agrementController.dispose(); _agrementController.dispose();
_agreementDateController.dispose();
_capacityController.dispose(); _capacityController.dispose();
_placesAvailableController.dispose();
super.dispose(); super.dispose();
} }
String? _validateBirthCity(String? v) {
final t = (v ?? '').trim();
if (t.isEmpty) return 'Ville requise';
if (t.length < 2) return 'Au moins 2 caractères';
return null;
}
String? _validateBirthCountry(String? v) {
final t = (v ?? '').trim();
if (t.isEmpty) return 'Pays requis';
if (t.length < 2) return 'Au moins 2 caractères';
return null;
}
String? _validatePlacesAvailable(String? v) {
if (v == null || v.isEmpty) return 'Places disponibles requises';
final n = int.tryParse(v);
if (n == null || n < 0) return 'Nombre entre 0 et la capacité';
if (n > kAmCapaciteAccueilMax) return 'Maximum $kAmCapaciteAccueilMax';
final cap = int.tryParse(_capacityController.text);
if (cap != null && n > cap) return 'Ne peut pas dépasser la capacité';
return null;
}
Future<void> _selectDate(BuildContext context) async { Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
@ -245,22 +154,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} }
} }
Future<void> _selectAgreementDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedAgreementDate ?? DateTime.now().subtract(const Duration(days: 365 * 5)),
firstDate: DateTime(1970, 1),
lastDate: DateTime.now(),
locale: const Locale('fr', 'FR'),
);
if (picked != null) {
setState(() {
_selectedAgreementDate = picked;
_agreementDateController.text = DateFormat('dd/MM/yyyy').format(picked);
});
}
}
Future<void> _pickPhoto() async { Future<void> _pickPhoto() async {
if (widget.onPickPhoto != null) { if (widget.onPickPhoto != null) {
await widget.onPickPhoto!(); await widget.onPickPhoto!();
@ -297,27 +190,9 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} catch (_) {} } catch (_) {}
} }
void _clearRegistrationPhoto() {
setState(() {
_photoBytes = null;
_photoFile = null;
_photoPathFramework = null;
_photoFilename = null;
_photoConsent = false;
});
}
void _submitForm() { void _submitForm() {
_applyPlaceNameFormat(_birthCityController);
_applyPlaceNameFormat(_birthCountryController);
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
if (!_hasRealPhoto) { if (_hasRealPhoto && !_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez ajouter une photo de profil.')),
);
return;
}
if (!_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')), const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
); );
@ -331,13 +206,11 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
photoFilename: _photoFilename, photoFilename: _photoFilename,
photoConsent: _photoConsent, photoConsent: _photoConsent,
dateOfBirth: _selectedDate, dateOfBirth: _selectedDate,
birthCity: _birthCityController.text.trim(), birthCity: _birthCityController.text,
birthCountry: _birthCountryController.text.trim(), birthCountry: _birthCountryController.text,
nir: normalizeNir(_nirController.text), nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text, agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text), capacity: int.tryParse(_capacityController.text),
placesAvailable: int.tryParse(_placesAvailableController.text),
); );
widget.onSubmit(data); widget.onSubmit(data);
@ -353,9 +226,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
return _buildCard(context, config, screenSize); return _buildCard(context, config, screenSize);
} }
return FocusTraversalGroup( return Scaffold(
policy: OrderedTraversalPolicy(),
child: Scaffold(
body: Stack( body: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
@ -363,7 +234,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0), padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -384,7 +255,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: config.isMobile ? 16 : 20), SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize), _buildCard(context, config, screenSize),
// Boutons mobile sous la carte // Boutons mobile sous la carte
@ -397,13 +268,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
), ),
), ),
// Chevrons desktop : visuellement sur les côtés ; ordre Tab 1011 = après « Places disponibles ». // Chevrons desktop uniquement
if (!config.isMobile) ...[ if (!config.isMobile) ...[
// Chevron Gauche (Retour)
Positioned( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
left: 40, left: 40,
child: FocusTraversalOrder(
order: const NumericFocusOrder(_kTabChevronPrev),
child: IconButton( child: IconButton(
icon: Transform( icon: Transform(
alignment: Alignment.center, alignment: Alignment.center,
@ -420,23 +290,19 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
tooltip: 'Précédent', tooltip: 'Précédent',
), ),
), ),
), // Chevron Droit (Suivant)
Positioned( Positioned(
top: screenSize.height / 2 - 20, top: screenSize.height / 2 - 20,
right: 40, right: 40,
child: FocusTraversalOrder(
order: const NumericFocusOrder(_kTabChevronNext),
child: IconButton( child: IconButton(
icon: Image.asset('assets/images/chevron_right.png', height: 40), icon: Image.asset('assets/images/chevron_right.png', height: 40),
onPressed: _submitForm, onPressed: _submitForm,
tooltip: 'Suivant', tooltip: 'Suivant',
), ),
), ),
),
], ],
], ],
), ),
),
); );
} }
@ -460,7 +326,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Container( Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6, width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28), vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
horizontal: config.isMobile ? 24 : 50, horizontal: config.isMobile ? 24 : 50,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -488,7 +354,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: config.isMobile ? 20.0 : 14.0), const SizedBox(height: 20),
], ],
config.isMobile config.isMobile
? _buildMobileFields(context, config) ? _buildMobileFields(context, config)
@ -610,7 +476,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 18),
// Contenu // Contenu
Expanded( Expanded(
@ -625,31 +491,42 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [ children: [
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: LayoutBuilder( child: Container(
builder: (context, constraints) { decoration: BoxDecoration(
final side = constraints.biggest.shortestSide; borderRadius: BorderRadius.circular(18),
return RegistrationPhotoSlot( boxShadow: [
side: side, BoxShadow(
imageBytes: _photoBytes, color: Colors.black.withOpacity(0.1),
imageFile: _photoFile, blurRadius: 10,
imagePathOrAsset: _photoPathFramework, offset: const Offset(0, 5),
baseShadowColor: Colors.green.shade300, ),
); ],
}, ),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _photoBytes != null && _photoBytes!.isNotEmpty
? Image.memory(_photoBytes!, fit: BoxFit.cover)
: _photoFile != null
? Image.file(_photoFile!, fit: BoxFit.cover)
: (_photoPathFramework != null &&
_photoPathFramework!.startsWith('assets/')
? Image.asset(_photoPathFramework!, fit: BoxFit.contain)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain)),
), ),
), ),
const SizedBox(height: 5), ),
const SizedBox(height: 10),
AppCustomCheckbox( AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.', label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent, value: _photoConsent,
onChanged: (_) {}, // Readonly onChanged: (v) {}, // Readonly
checkboxSize: 22.0, checkboxSize: 22.0,
fontSize: 14.0, fontSize: 14.0,
), ),
], ],
), ),
), ),
const SizedBox(width: 24), const SizedBox(width: 32),
// CHAMPS (2/3) - Layout optimisé compact // CHAMPS (2/3) - Layout optimisé compact
Expanded( Expanded(
@ -657,56 +534,32 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Ligne 1 : Date de naissance + Ville // Ligne 1 : Ville + Pays
Row(
children: [
Expanded(child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
const SizedBox(width: 16),
Expanded(child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
],
),
const SizedBox(height: 12),
// Ligne 2 : Date + NIR (NIR prend plus de place si possible ou 50/50)
Row( Row(
children: [ children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
],
),
const SizedBox(height: 5),
// Ligne 2 : Pays + NIR
Row(
children: [
Expanded(flex: 2, child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))), Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
], ],
), ),
const SizedBox(height: 5), const SizedBox(height: 12),
// Ligne 3 : N° agrément + Date d'obtention // Ligne 3 : Agrément + Capacité
Row( Row(
children: [ children: [
Expanded( Expanded(flex: 3, child: _buildReadonlyField('N° Agrément', _agrementController.text)),
flex: 3,
child: _buildReadonlyField('N° d\'agrément', _agrementController.text),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(flex: 2, child: _buildReadonlyField('Capacité', _capacityController.text)),
flex: 2,
child: _buildReadonlyField(
'Date d\'obtention de l\'agrément',
_agreementDateController.text.isNotEmpty ? _agreementDateController.text : '',
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
Expanded(
child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
),
const SizedBox(width: 16),
Expanded(
child: _buildReadonlyField(
'Places disponibles',
_placesAvailableController.text,
),
),
], ],
), ),
], ],
@ -735,10 +588,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [ children: [
Text( Text(
label, label,
style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600), style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 3), const SizedBox(height: 4),
Container( Container(
width: double.infinity, width: double.infinity,
height: 45.0, // Hauteur réduite pour compacter height: 45.0, // Hauteur réduite pour compacter
@ -752,7 +605,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
), ),
child: Text( child: Text(
value.isNotEmpty ? value : '-', value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: 14.0), style: GoogleFonts.merienda(fontSize: 16.0),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@ -762,7 +615,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
/// Layout DESKTOP : Photo à gauche, champs à droite /// Layout DESKTOP : Photo à gauche, champs à droite
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) { Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
final double verticalSpacing = config.isReadonly ? 8.0 : 14.0; final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -775,11 +628,27 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
width: 300, width: 300,
child: _buildPhotoSection(context, config), child: _buildPhotoSection(context, config),
), ),
const SizedBox(width: 22), const SizedBox(width: 30),
// Champs à droite // Champs à droite
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Votre ville de naissance',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Votre pays de naissance',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
),
SizedBox(height: verticalSpacing),
_buildField( _buildField(
config: config, config: config,
label: 'Date de naissance', label: 'Date de naissance',
@ -789,30 +658,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
onTap: () => _selectDate(context), onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today, suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null, validator: (v) => _selectedDate == null ? 'Date requise' : null,
focusTraversalOrder: _kTabDateBirth,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: _validateBirthCity,
focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Ex. France, Maroc…',
validator: _validateBirthCountry,
focusNode: _birthCountryFocus,
focusTraversalOrder: _kTabBirthCountry,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
onEditingComplete: () => _nirFocus?.requestFocus(),
), ),
], ],
), ),
@ -822,17 +667,13 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
NirTextField( NirTextField(
controller: _nirController, controller: _nirController,
focusNode: _nirFocus,
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity, fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0, fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 20.0, labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 18.0, inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: _buildField( child: _buildField(
@ -841,61 +682,22 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _agrementController, controller: _agrementController,
hint: 'Votre numéro d\'agrément', hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null, validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
focusTraversalOrder: _kTabAgrement,
), ),
), ),
const SizedBox(width: 14), const SizedBox(width: 20),
Expanded(
child: _buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
focusTraversalOrder: _kTabAgreementDate,
),
),
],
),
SizedBox(height: verticalSpacing),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded( Expanded(
child: _buildField( child: _buildField(
config: config, config: config,
label: 'Capacité d\'accueil', label: 'Capacité d\'accueil',
controller: _capacityController, controller: _capacityController,
hint: 'De 1 à $kAmCapaciteAccueilMax enfants', hint: 'Ex: 3',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) { validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise'; if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v); final n = int.tryParse(v);
if (n == null || n < 1) { if (n == null || n <= 0) return 'Nombre invalide';
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null; return null;
}, },
focusTraversalOrder: _kTabCapacity,
),
),
const SizedBox(width: 14),
Expanded(
child: _buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
), ),
), ),
], ],
@ -913,27 +715,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_buildPhotoSection(context, config), _buildPhotoSection(context, config),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildField(
config: config,
label: 'Date de naissance',
controller: _dateOfBirthController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
focusTraversalOrder: _kTabDateBirth,
),
const SizedBox(height: 12),
_buildField( _buildField(
config: config, config: config,
label: 'Ville de naissance', label: 'Ville de naissance',
controller: _birthCityController, controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…', hint: 'Votre ville de naissance',
validator: _validateBirthCity, validator: (v) => v!.isEmpty ? 'Ville requise' : null,
focusNode: _birthCityFocus,
focusTraversalOrder: _kTabBirthCity,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -941,20 +728,25 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
config: config, config: config,
label: 'Pays de naissance', label: 'Pays de naissance',
controller: _birthCountryController, controller: _birthCountryController,
hint: 'Ex. France, Maroc…', hint: 'Votre pays de naissance',
validator: _validateBirthCountry, validator: (v) => v!.isEmpty ? 'Pays requis' : null,
focusNode: _birthCountryFocus, ),
focusTraversalOrder: _kTabBirthCountry, const SizedBox(height: 12),
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _nirFocus?.requestFocus(), _buildField(
onEditingComplete: () => _nirFocus?.requestFocus(), config: config,
label: 'Date de naissance',
controller: _dateOfBirthController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
NirTextField( NirTextField(
controller: _nirController, controller: _nirController,
focusNode: _nirFocus,
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
fieldWidth: double.infinity, fieldWidth: double.infinity,
fieldHeight: 45.0, fieldHeight: 45.0,
labelFontSize: 15.0, labelFontSize: 15.0,
@ -968,90 +760,72 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _agrementController, controller: _agrementController,
hint: 'Votre numéro d\'agrément', hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null, validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
focusTraversalOrder: _kTabAgrement,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
focusTraversalOrder: _kTabAgreementDate,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildField( _buildField(
config: config, config: config,
label: 'Capacité d\'accueil', label: 'Capacité d\'accueil',
controller: _capacityController, controller: _capacityController,
hint: 'De 1 à $kAmCapaciteAccueilMax enfants', hint: 'Ex: 3',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) { validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise'; if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v); final n = int.tryParse(v);
if (n == null || n < 1) { if (n == null || n <= 0) return 'Nombre invalide';
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null; return null;
}, },
focusTraversalOrder: _kTabCapacity,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
focusTraversalOrder: _kTabPlaces,
), ),
], ],
); );
} }
/// Section photo + checkbox (même logique visuelle que les enfants : cadre, croix). /// Section photo + checkbox
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) { Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
final photoSize = config.isMobile ? 200.0 : 270.0; final Color baseCardColorForShadow = Colors.green.shade300;
final scaleFactor = config.isMobile ? 0.9 : 1.1; final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
final slot = RegistrationPhotoSlot( ImageProvider? currentImageProvider;
side: photoSize, if (_photoBytes != null && _photoBytes!.isNotEmpty) {
scaleFactor: scaleFactor, currentImageProvider = MemoryImage(_photoBytes!);
imageBytes: _photoBytes, } else if (_photoFile != null) {
imageFile: _photoFile, currentImageProvider = FileImage(_photoFile!);
imagePathOrAsset: _photoPathFramework, } else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
onTapPick: config.isReadonly ? null : _pickPhoto, currentImageProvider = AssetImage(_photoPathFramework!);
onClear: config.isReadonly ? null : _clearRegistrationPhoto, }
baseShadowColor: Colors.green.shade300,
isMobile: config.isMobile, final photoSize = config.isMobile ? 200.0 : 270.0;
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (config.isReadonly) HoverReliefWidget(
slot onPressed: _pickPhoto,
else borderRadius: BorderRadius.circular(10.0),
FocusTraversalOrder( initialShadowColor: initialPhotoShadow,
order: const NumericFocusOrder(_kTabPhotoPick), hoverShadowColor: hoverPhotoShadow,
child: slot, child: SizedBox(
height: photoSize,
width: photoSize,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: currentImageProvider != null
? DecorationImage(image: currentImageProvider, fit: BoxFit.cover)
: null,
), ),
SizedBox(height: config.isMobile ? 10.0 : 6.0), child: currentImageProvider == null
? Image.asset('assets/images/photo.png', fit: BoxFit.contain)
: null,
),
),
),
const SizedBox(height: 10),
AppCustomCheckbox( AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.', label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent, value: _photoConsent,
onChanged: (val) { onChanged: (val) => setState(() => _photoConsent = val == true),
if (config.isReadonly) return;
setState(() => _photoConsent = val == true);
},
), ),
], ],
); );
@ -1069,11 +843,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
IconData? suffixIcon, IconData? suffixIcon,
String? Function(String?)? validator, String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
double? focusTraversalOrder,
TextInputAction? textInputAction,
ValueChanged<String>? onFieldSubmitted,
VoidCallback? onEditingComplete,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
return FormFieldWrapper( return FormFieldWrapper(
@ -1082,34 +851,21 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
value: controller.text, value: controller.text,
); );
} else { } else {
final field = CustomAppTextField( return CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: label, labelText: label,
hintText: hint ?? label, hintText: hint ?? label,
fieldWidth: double.infinity, fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0, fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 20.0, labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 18.0, inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
keyboardType: keyboardType ?? TextInputType.text, keyboardType: keyboardType ?? TextInputType.text,
readOnly: readOnly, readOnly: readOnly,
onTap: onTap, onTap: onTap,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
validator: validator, validator: validator,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
onEditingComplete: onEditingComplete,
); );
final o = focusTraversalOrder;
if (o != null) {
return FocusTraversalOrder(
order: NumericFocusOrder(o),
child: field,
);
}
return field;
} }
} }

View File

@ -1,169 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'hover_relief_widget.dart';
/// Cadre « croquis » par-dessus la photo (même ressource que les cartes enfant).
const String kRegistrationPhotoFrameAsset = 'assets/images/photo_frame.png';
/// Indique si une photo « réelle » est présente (hors seul placeholder asset).
bool registrationPhotoSlotHasImage({
Uint8List? imageBytes,
File? imageFile,
String? imagePathOrAsset,
}) {
if (imageBytes != null && imageBytes.isNotEmpty) return true;
if (imageFile != null) return true;
if (imagePathOrAsset == null || imagePathOrAsset.isEmpty) return false;
return !imagePathOrAsset.startsWith('assets/');
}
/// Zone photo uniforme : cadre gris / placeholder, tap pour choisir, croix pour retirer, cadre dessin si photo.
///
/// Utilisé inscription **enfants** ([ChildCardWidget]) et **AM** ([ProfessionalInfoFormScreen]).
class RegistrationPhotoSlot extends StatelessWidget {
final double side;
final double scaleFactor;
final Uint8List? imageBytes;
final File? imageFile;
/// Chemin fichier local (hors `assets/`) ou URI asset `assets/...`.
final String? imagePathOrAsset;
final VoidCallback? onTapPick;
final VoidCallback? onClear;
final Color baseShadowColor;
final String placeholderAsset;
final bool isMobile;
const RegistrationPhotoSlot({
super.key,
required this.side,
this.scaleFactor = 1.0,
this.imageBytes,
this.imageFile,
this.imagePathOrAsset,
this.onTapPick,
this.onClear,
required this.baseShadowColor,
this.placeholderAsset = 'assets/images/photo.png',
this.isMobile = false,
});
bool get _hasImage => registrationPhotoSlotHasImage(
imageBytes: imageBytes,
imageFile: imageFile,
imagePathOrAsset: imagePathOrAsset,
);
Widget _buildImage({required BoxFit fit}) {
final b = imageBytes;
if (b != null && b.isNotEmpty) {
return Image.memory(b, fit: fit);
}
final f = imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
final p = imagePathOrAsset;
if (p != null && p.isNotEmpty) {
if (p.startsWith('assets/')) {
return Image.asset(p, fit: fit);
}
if (!kIsWeb) {
try {
final file = File(p);
if (file.existsSync()) {
return Image.file(file, fit: fit);
}
} catch (_) {}
}
}
return Image.asset(placeholderAsset, fit: BoxFit.contain);
}
@override
Widget build(BuildContext context) {
final canPick = onTapPick != null;
final canClear = onClear != null && _hasImage;
final outerRadius = BorderRadius.circular(10 * scaleFactor);
final photoClipRadius = BorderRadius.circular(side * 0.14);
final initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseShadowColor);
final hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseShadowColor);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canPick ? onTapPick : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
clipBehavior: _hasImage ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: side,
height: side,
child: !_hasImage
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
placeholderAsset,
fit: BoxFit.contain,
width: side,
height: side,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildImage(fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
kRegistrationPhotoFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (canClear)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onClear,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: isMobile ? 26 : 30,
height: isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
}

View File

@ -2,10 +2,10 @@
* POST /api/v1/auth/register/parent jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed). * 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 * Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
* *
* Les PNG dans tests/ressources/photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale * 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. * via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
* *
* Usage : node tests/scripts/register-parent-durand-rousseau-test.mjs [BASE_URL] * Usage : node scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
*/ */
import fs from 'fs'; import fs from 'fs';
@ -17,8 +17,8 @@ import { execSync } from 'child_process';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..', '..'); const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(__dirname, '..', 'ressources', 'photos'); const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function shrinkToJpeg(inputPath, label) { function shrinkToJpeg(inputPath, label) {
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`); const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
@ -41,8 +41,8 @@ const presentationDossier =
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " + "Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
"Merci pour l'étude de notre dossier."; "Merci pour l'étude de notre dossier.";
const chloeSrc = path.join(photosDir, 'rousseau-chloe.png'); const chloeSrc = path.join(photosDir, 'G_Chloé.png');
const hugoSrc = path.join(photosDir, 'rousseau-hugo.png'); const hugoSrc = path.join(photosDir, 'G_Hugo.png');
let chloeJpg; let chloeJpg;
let hugoJpg; let hugoJpg;

View File

@ -2,7 +2,7 @@
* POST /api/v1/auth/register/parent jeu de test officiel David LECOMTE (père isolé). * POST /api/v1/auth/register/parent jeu de test officiel David LECOMTE (père isolé).
* Email : david.lecomte@ptits-pas.fr * Email : david.lecomte@ptits-pas.fr
* *
* Usage : node tests/scripts/register-parent-lecomte-test.mjs [BASE_URL] * Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL]
*/ */
import fs from 'fs'; import fs from 'fs';
@ -12,7 +12,8 @@ import http from 'http';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const photosDir = path.join(__dirname, '..', 'ressources', 'photos'); const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) { function toDataUri(filePath) {
const buf = fs.readFileSync(filePath); const buf = fs.readFileSync(filePath);
@ -39,7 +40,7 @@ const body = {
nom: 'LECOMTE', nom: 'LECOMTE',
date_naissance: '2023-04-15', date_naissance: '2023-04-15',
genre: 'H', genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')), photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')),
photo_filename: 'maxime_lecomte.png', photo_filename: 'maxime_lecomte.png',
grossesse_multiple: false, grossesse_multiple: false,
}, },

View File

@ -2,8 +2,8 @@
* POST /api/v1/auth/register/parent jeu de test officiel famille MARTIN (docs/test-data + seed). * 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 * Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr
* *
* Usage : node tests/scripts/register-parent-martin-test.mjs [BASE_URL] * Usage : node scripts/register-parent-martin-test.mjs [BASE_URL]
* Ex. : node tests/scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr * Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
*/ */
import fs from 'fs'; import fs from 'fs';
@ -13,7 +13,8 @@ import http from 'http';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const photosDir = path.join(__dirname, '..', 'ressources', 'photos'); const repoRoot = path.join(__dirname, '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) { function toDataUri(filePath) {
const buf = fs.readFileSync(filePath); const buf = fs.readFileSync(filePath);
@ -45,7 +46,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'F', genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')), photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')),
photo_filename: 'emma_martin.png', photo_filename: 'emma_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },
@ -54,7 +55,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'H', genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')), photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')),
photo_filename: 'noah_martin.png', photo_filename: 'noah_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },
@ -63,7 +64,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'F', genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')), photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')),
photo_filename: 'lea_martin.png', photo_filename: 'lea_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },

View File

@ -1,128 +0,0 @@
/**
* POST /api/v1/auth/register/am jeu de test officiel Marie DUBOIS (docs/test-data + seed).
* Email : marie.dubois@ptits-pas.fr
*
* Photo attendue : tests/ressources/photos/dubois-marie.png
* Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
* (réduction JPEG locale via sharp-cli si besoin, comme les autres scripts lourds)
*
* Usage : node tests/scripts/register-am-dubois-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(__dirname, '..', '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 photoSrc = path.join(photosDir, 'dubois-marie.png');
let photoPath;
try {
console.error('Préparation photo (sharp-cli)…');
photoPath = shrinkToJpeg(photoSrc, 'am-dubois');
const body = {
email: 'marie.dubois@ptits-pas.fr',
prenom: 'Marie',
nom: 'DUBOIS',
telephone: '0696345678',
adresse: '25 Rue de la République',
code_postal: '95870',
ville: 'Bezons',
photo_base64: toDataUri(photoPath),
photo_filename: 'marie_dubois.jpg',
consentement_photo: true,
date_naissance: '1980-06-08',
lieu_naissance_ville: 'Ajaccio',
lieu_naissance_pays: 'France',
nir: '280062A00100191',
numero_agrement: 'AGR-2019-095001',
date_agrement: '2019-09-01',
capacite_accueil: 4,
places_disponibles: 2,
biographie:
"Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. " +
'Accueil bienveillant et cadre sécurisant. 2 places disponibles.',
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/am', `${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 (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
} catch {
/* ignore */
}
}

View File

@ -1,127 +0,0 @@
/**
* POST /api/v1/auth/register/am jeu de test officiel Fatima EL MANSOURI (docs/test-data + seed).
* Email : fatima.elmansouri@ptits-pas.fr
*
* Photo attendue : tests/ressources/photos/mansouri-fatima.png
* Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
*
* Usage : node tests/scripts/register-am-mansouri-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(__dirname, '..', '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 photoSrc = path.join(photosDir, 'mansouri-fatima.png');
let photoPath;
try {
console.error('Préparation photo (sharp-cli)…');
photoPath = shrinkToJpeg(photoSrc, 'am-mansouri');
const body = {
email: 'fatima.elmansouri@ptits-pas.fr',
prenom: 'Fatima',
nom: 'EL MANSOURI',
telephone: '0675456789',
adresse: '17 Boulevard Aristide Briand',
code_postal: '95870',
ville: 'Bezons',
photo_base64: toDataUri(photoPath),
photo_filename: 'fatima_elmansouri.jpg',
consentement_photo: true,
date_naissance: '1975-11-12',
lieu_naissance_ville: 'Casablanca',
lieu_naissance_pays: 'Maroc',
nir: '275119900100102',
numero_agrement: 'AGR-2017-095002',
date_agrement: '2017-06-15',
capacite_accueil: 3,
places_disponibles: 1,
biographie:
"Assistante maternelle expérimentée. Née à l'étranger. Spécialité 1-3 ans. " +
'Accueil à la journée. 1 place disponible.',
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/am', `${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 (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
} catch {
/* ignore */
}
}