Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8104e7e4 | ||
|
|
c4d93ee458 | ||
|
|
1fca0cf132 | ||
|
|
b16dd4b55c | ||
|
|
8682421453 | ||
|
|
31bd8c3175 | ||
|
|
c94f2cf0d5 | ||
|
|
dfe7daed14 | ||
|
|
111935e451 | ||
|
|
ae3292a7fc | ||
|
|
8e8c6d79b1 | ||
|
|
6752dc97b4 | ||
|
|
31857ec891 | ||
|
|
ca7ef862da | ||
|
|
11aa66feff | ||
|
|
358eefdab3 | ||
|
|
d23f3c9f4f | ||
|
|
1834eb8c79 | ||
|
|
0386785f81 | ||
|
|
c43f55bed6 | ||
|
|
0c48a5c06f | ||
|
|
be8b1f23ed | ||
|
|
18b270eaa3 | ||
|
|
68e4f54814 | ||
|
|
6794190916 | ||
|
|
790761d576 | ||
|
|
930097f87d | ||
|
|
18af5c9034 | ||
|
|
10bf2553e7 | ||
|
|
678f4219b5 | ||
|
|
5295e8ec72 | ||
|
|
480f4a9396 | ||
|
|
6bf0932da8 | ||
|
|
2f1740b35f | ||
|
|
fd97e68dd9 | ||
|
|
813fdb8449 | ||
|
|
155c6ca4d5 | ||
|
|
030ef81038 | ||
|
|
0d88597bb6 | ||
|
|
9b007fe490 | ||
|
|
7ecb99963c | ||
|
|
39814c76b1 | ||
|
|
b956f94ad2 | ||
|
|
b18d5c8a9e | ||
|
|
6ad88cbbc6 | ||
|
|
dfe91ed772 | ||
|
|
08612c455d | ||
|
|
6452706680 | ||
|
|
eea94769bf | ||
|
|
bdecbc2c1d | ||
|
|
f8bd911c02 | ||
|
|
b79f8c7e64 | ||
|
|
a57993a90f | ||
|
|
1d774f29eb | ||
|
|
890619ff59 | ||
|
|
5d7eb9eb36 | ||
|
|
45bd8a9ef1 | ||
|
|
acb8e72a7c | ||
|
|
b6c70a52ac | ||
|
|
96794919a8 | ||
|
|
271dc713a3 | ||
|
|
13741b0430 | ||
|
|
8e3af711e5 | ||
|
|
e700e50924 | ||
|
|
36ef0f8d5c | ||
|
|
f09deb5efc | ||
|
|
26a0e31b32 | ||
|
|
21430dca41 | ||
|
|
dcb81d3feb | ||
|
|
7c86feeb78 | ||
|
|
df87abbb85 | ||
|
|
bd81561e41 | ||
|
|
3d13eb5b2e | ||
|
|
5b37d09fa9 | ||
|
|
53f3af9794 | ||
|
|
105cf53e7b | ||
|
|
29bee9fa80 | ||
|
|
dbd56637e1 |
@@ -0,0 +1,18 @@
|
||||
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Fichiers binaires : pas de conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
|
||||
# Scripts shell : toujours LF
|
||||
*.sh text eol=lf
|
||||
@@ -37,6 +37,10 @@ yarn-error.log*
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
||||
**/windows/flutter/generated_plugin_registrant.cc
|
||||
**/windows/flutter/generated_plugin_registrant.h
|
||||
**/windows/flutter/generated_plugins.cmake
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
@@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d
|
||||
|
||||
# Environnement
|
||||
NODE_ENV=development
|
||||
|
||||
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
|
||||
# LOG_API_REQUESTS=true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Test POST /auth/register/am (ticket #90)
|
||||
# Usage: ./scripts/test-register-am.sh [BASE_URL]
|
||||
# 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}"
|
||||
echo "Testing POST $BASE_URL/auth/register/am"
|
||||
echo "---"
|
||||
|
||||
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "marie.dupont.test@ptits-pas.fr",
|
||||
"prenom": "Marie",
|
||||
"nom": "DUPONT",
|
||||
"telephone": "0612345678",
|
||||
"adresse": "1 rue Test",
|
||||
"code_postal": "75001",
|
||||
"ville": "Paris",
|
||||
"consentement_photo": true,
|
||||
"nir": "123456789012345",
|
||||
"numero_agrement": "AGR-2024-001",
|
||||
"capacite_accueil": 4,
|
||||
"acceptation_cgu": true,
|
||||
"acceptation_privacy": true
|
||||
}'
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request } from 'express';
|
||||
|
||||
/** Clés à masquer dans les logs (corps de requête) */
|
||||
const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
'smtp_password',
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'secret',
|
||||
];
|
||||
|
||||
function maskBody(body: unknown): unknown {
|
||||
if (body === null || body === undefined) return body;
|
||||
if (typeof body !== 'object') return body;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const lower = key.toLowerCase();
|
||||
const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s));
|
||||
out[key] = isSensitive ? '***' : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LogRequestInterceptor implements NestInterceptor {
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.enabled =
|
||||
process.env.LOG_API_REQUESTS === 'true' ||
|
||||
process.env.LOG_API_REQUESTS === '1';
|
||||
}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
if (!this.enabled) return next.handle();
|
||||
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<Request>();
|
||||
const { method, url, body, query } = req;
|
||||
const hasBody = body && Object.keys(body).length > 0;
|
||||
|
||||
const logLine = [
|
||||
`[API] ${method} ${url}`,
|
||||
Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '',
|
||||
hasBody ? `body=${JSON.stringify(maskBody(body))}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
console.log(logLine);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
// Optionnel: log du statut en fin de requête (si besoin plus tard)
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
|
||||
import { DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AuthGuard } from './common/guards/auth.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule,
|
||||
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
|
||||
|
||||
|
||||
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||
|
||||
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
|
||||
app.enableCors({
|
||||
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
|
||||
|
||||
@@ -53,8 +53,7 @@ export class ConfigController {
|
||||
// @Roles('super_admin')
|
||||
async completeSetup(@Request() req: any) {
|
||||
try {
|
||||
// TODO: Récupérer l'ID utilisateur depuis le JWT
|
||||
const userId = req.user?.id || 'system';
|
||||
const userId = req.user?.id ?? null;
|
||||
|
||||
await this.configService.markSetupCompleted(userId);
|
||||
|
||||
|
||||
@@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Marquer la configuration initiale comme terminée
|
||||
* @param userId ID de l'utilisateur qui termine la configuration
|
||||
* @param userId ID de l'utilisateur qui termine la configuration (null si non authentifié)
|
||||
*/
|
||||
async markSetupCompleted(userId: string): Promise<void> {
|
||||
await this.set('setup_completed', 'true', userId);
|
||||
async markSetupCompleted(userId: string | null): Promise<void> {
|
||||
await this.set('setup_completed', 'true', userId ?? undefined);
|
||||
this.logger.log('✅ Configuration initiale marquée comme terminée');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export class AssistantesMaternellesController {
|
||||
return this.assistantesMaternellesService.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
||||
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
||||
|
||||
@@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from 'src/common/decorators/public.decorator';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RegisterParentDto } from './dto/register-parent.dto';
|
||||
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
@@ -53,12 +53,16 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register/parent/legacy')
|
||||
@ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' })
|
||||
@ApiResponse({ status: 201, description: 'Inscription réussie' })
|
||||
@Post('register/am')
|
||||
@ApiOperation({
|
||||
summary: 'Inscription Assistante Maternelle COMPLÈTE',
|
||||
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
|
||||
})
|
||||
@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: 409, description: 'Email déjà utilisé' })
|
||||
async registerParentLegacy(@Body() dto: RegisterParentDto) {
|
||||
return this.authService.registerParent(dto);
|
||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
|
||||
return this.authService.inscrireAMComplet(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AppConfigModule } from 'src/modules/config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users, Parents, Children]),
|
||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||
forwardRef(() => UserModule),
|
||||
AppConfigModule,
|
||||
JwtModule.registerAsync({
|
||||
|
||||
@@ -13,13 +13,14 @@ import * as crypto from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RegisterParentDto } from './dto/register-parent.dto';
|
||||
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
|
||||
@@ -116,7 +117,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM()
|
||||
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||||
* @deprecated
|
||||
*/
|
||||
async register(registerDto: RegisterDto) {
|
||||
@@ -157,125 +158,6 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent (étape 1/6 du workflow CDC)
|
||||
* SANS mot de passe - Token de création MDP généré
|
||||
*/
|
||||
async registerParent(dto: RegisterParentDto) {
|
||||
// 1. Vérifier que l'email n'existe pas
|
||||
const exists = await this.usersService.findByEmailOrNull(dto.email);
|
||||
if (exists) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||
}
|
||||
|
||||
// 2. Vérifier l'email du co-parent s'il existe
|
||||
if (dto.co_parent_email) {
|
||||
const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||
if (coParentExists) {
|
||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Récupérer la durée d'expiration du token depuis la config
|
||||
const tokenExpiryDays = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
);
|
||||
|
||||
// 4. Générer les tokens de création de mot de passe
|
||||
const tokenCreationMdp = crypto.randomUUID();
|
||||
const tokenExpiration = new Date();
|
||||
tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays);
|
||||
|
||||
// 5. Transaction : Créer Parent 1 + Parent 2 (si existe) + entités parents
|
||||
const result = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
// Créer Parent 1
|
||||
const parent1 = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: tokenExpiration,
|
||||
});
|
||||
|
||||
const savedParent1 = await manager.save(Users, parent1);
|
||||
|
||||
// Créer Parent 2 si renseigné
|
||||
let savedParent2: Users | null = null;
|
||||
let tokenCoParent: string | null = null;
|
||||
|
||||
if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) {
|
||||
tokenCoParent = crypto.randomUUID();
|
||||
const tokenExpirationCoParent = new Date();
|
||||
tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays);
|
||||
|
||||
const parent2 = manager.create(Users, {
|
||||
email: dto.co_parent_email,
|
||||
prenom: dto.co_parent_prenom,
|
||||
nom: dto.co_parent_nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.co_parent_telephone,
|
||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
||||
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
||||
token_creation_mdp: tokenCoParent,
|
||||
token_creation_mdp_expire_le: tokenExpirationCoParent,
|
||||
});
|
||||
|
||||
savedParent2 = await manager.save(Users, parent2);
|
||||
}
|
||||
|
||||
// Créer l'entité métier Parents pour Parent 1
|
||||
const parentEntity = manager.create(Parents, {
|
||||
user_id: savedParent1.id,
|
||||
});
|
||||
parentEntity.user = savedParent1;
|
||||
if (savedParent2) {
|
||||
parentEntity.co_parent = savedParent2;
|
||||
}
|
||||
|
||||
await manager.save(Parents, parentEntity);
|
||||
|
||||
// Créer l'entité métier Parents pour Parent 2 (si existe)
|
||||
if (savedParent2) {
|
||||
const coParentEntity = manager.create(Parents, {
|
||||
user_id: savedParent2.id,
|
||||
});
|
||||
coParentEntity.user = savedParent2;
|
||||
coParentEntity.co_parent = savedParent1;
|
||||
|
||||
await manager.save(Parents, coParentEntity);
|
||||
}
|
||||
|
||||
return {
|
||||
parent1: savedParent1,
|
||||
parent2: savedParent2,
|
||||
tokenCreationMdp,
|
||||
tokenCoParent,
|
||||
};
|
||||
});
|
||||
|
||||
// 6. TODO: Envoyer email avec lien de création de MDP
|
||||
// await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp);
|
||||
// if (result.parent2 && result.tokenCoParent) {
|
||||
// await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent);
|
||||
// }
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Un email de validation vous a été envoyé.',
|
||||
parent_id: result.parent1.id,
|
||||
co_parent_id: result.parent2?.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||
@@ -432,6 +314,82 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
throw new BadRequestException(
|
||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||
);
|
||||
}
|
||||
|
||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||
if (existe) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||
}
|
||||
|
||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
);
|
||||
const tokenCreationMdp = crypto.randomUUID();
|
||||
const dateExpiration = new Date();
|
||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||
|
||||
let urlPhoto: string | null = null;
|
||||
if (dto.photo_base64 && dto.photo_filename) {
|
||||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
|
||||
}
|
||||
|
||||
const dateConsentementPhoto =
|
||||
dto.consentement_photo ? new Date() : undefined;
|
||||
|
||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: dto.nir,
|
||||
max_children: dto.capacite_accueil,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
available: true,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
|
||||
return {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Matches,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterAMCompletDto {
|
||||
// ============================================
|
||||
// ÉTAPE 1 : IDENTITÉ (Obligatoire)
|
||||
// ============================================
|
||||
|
||||
@ApiProperty({ example: 'marie.dupont@ptits-pas.fr' })
|
||||
@IsEmail({}, { message: 'Email invalide' })
|
||||
@IsNotEmpty({ message: "L'email est requis" })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Marie' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100)
|
||||
prenom: string;
|
||||
|
||||
@ApiProperty({ example: 'DUPONT' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100)
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ example: '0689567890' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||
})
|
||||
telephone: string;
|
||||
|
||||
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiProperty({ example: '95870', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiProperty({ example: 'Bezons', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2 : PHOTO + INFOS PRO
|
||||
// ============================================
|
||||
|
||||
@ApiProperty({
|
||||
example: 'data:image/jpeg;base64,/9j/4AAQ...',
|
||||
required: false,
|
||||
description: 'Photo de profil en base64',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_base64?: string;
|
||||
|
||||
@ApiProperty({ example: 'photo_profil.jpg', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_filename?: string;
|
||||
|
||||
@ApiProperty({ example: true, description: 'Consentement utilisation photo' })
|
||||
@IsBoolean()
|
||||
@IsNotEmpty({ message: 'Le consentement photo est requis' })
|
||||
consentement_photo: boolean;
|
||||
|
||||
@ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date_naissance?: string;
|
||||
|
||||
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_ville?: string;
|
||||
|
||||
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_pays?: string;
|
||||
|
||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le NIR est requis' })
|
||||
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
|
||||
nir: string;
|
||||
|
||||
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: "Le numéro d'agrément est requis" })
|
||||
@MaxLength(50)
|
||||
numero_agrement: string;
|
||||
|
||||
@ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrément" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date_agrement?: string;
|
||||
|
||||
@ApiProperty({ example: 4, description: 'Capacité d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 })
|
||||
@IsInt()
|
||||
@Min(1, { message: 'La capacité doit être au moins 1' })
|
||||
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
||||
capacite_accueil: number;
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
|
||||
// ============================================
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Assistante maternelle expérimentée, accueil bienveillant...',
|
||||
required: false,
|
||||
description: 'Présentation / biographie (max 2000 caractères)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' })
|
||||
biographie?: string;
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 4 : ACCEPTATION CGU (Obligatoire)
|
||||
// ============================================
|
||||
|
||||
@ApiProperty({ example: true, description: "Acceptation des CGU" })
|
||||
@IsBoolean()
|
||||
@IsNotEmpty({ message: "L'acceptation des CGU est requise" })
|
||||
acceptation_cgu: boolean;
|
||||
|
||||
@ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' })
|
||||
@IsBoolean()
|
||||
@IsNotEmpty({ message: "L'acceptation de la politique de confidentialité est requise" })
|
||||
acceptation_privacy: boolean;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { SituationFamilialeType } from 'src/entities/users.entity';
|
||||
|
||||
export class RegisterParentDto {
|
||||
// === Informations obligatoires ===
|
||||
@ApiProperty({ example: 'claire.martin@ptits-pas.fr' })
|
||||
@IsEmail({}, { message: 'Email invalide' })
|
||||
@IsNotEmpty({ message: 'L\'email est requis' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Claire' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' })
|
||||
prenom: string;
|
||||
|
||||
@ApiProperty({ example: 'MARTIN' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' })
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ example: '0689567890' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||
})
|
||||
telephone: string;
|
||||
|
||||
// === Informations optionnelles ===
|
||||
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiProperty({ example: '95870', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiProperty({ example: 'Bezons', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
// === Informations co-parent (optionnel) ===
|
||||
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Email du co-parent invalide' })
|
||||
co_parent_email?: string;
|
||||
|
||||
@ApiProperty({ example: 'Thomas', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_prenom?: string;
|
||||
|
||||
@ApiProperty({ example: 'MARTIN', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_nom?: string;
|
||||
|
||||
@ApiProperty({ example: '0612345678', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone du co-parent doit être valide',
|
||||
})
|
||||
co_parent_telephone?: string;
|
||||
|
||||
@ApiProperty({ example: 'true', description: 'Le co-parent habite à la même adresse', required: false })
|
||||
@IsOptional()
|
||||
co_parent_meme_adresse?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_adresse?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_code_postal?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_ville?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
export class ParentsController {
|
||||
constructor(private readonly parentsService: ParentsService) {}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
|
||||
@@ -35,7 +35,7 @@ export class GestionnairesController {
|
||||
return this.gestionnairesService.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Liste des gestionnaires' })
|
||||
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
|
||||
@Get()
|
||||
|
||||
@@ -3,9 +3,13 @@ import { GestionnairesService } from './gestionnaires.service';
|
||||
import { GestionnairesController } from './gestionnaires.controller';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Users])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [GestionnairesController],
|
||||
providers: [GestionnairesService],
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ export class UserController {
|
||||
|
||||
// Lister tous les utilisateurs (super_admin uniquement)
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
|
||||
findAll() {
|
||||
return this.userService.findAll();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ParentsModule } from '../parents/parents.module';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@@ -20,6 +21,7 @@ import { Parents } from 'src/entities/parents.entity';
|
||||
]), forwardRef(() => AuthModule),
|
||||
ParentsModule,
|
||||
AssistantesMaternellesModule,
|
||||
GestionnairesModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||
CREATE TABLE assistantes_maternelles (
|
||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
numero_agrement VARCHAR(50),
|
||||
date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3
|
||||
nir_chiffre CHAR(15),
|
||||
nb_max_enfants INT,
|
||||
place_disponible INT,
|
||||
biographie TEXT,
|
||||
disponible BOOLEAN DEFAULT true
|
||||
disponible BOOLEAN DEFAULT true,
|
||||
ville_residence VARCHAR(100),
|
||||
date_agrement DATE,
|
||||
annee_experience SMALLINT,
|
||||
specialite VARCHAR(100),
|
||||
place_disponible INT
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
|
||||
@@ -41,6 +41,16 @@ docker compose -f docker-compose.dev.yml down -v
|
||||
---
|
||||
|
||||
|
||||
## Réinitialiser la BDD et charger les données de test (dashboard admin)
|
||||
|
||||
Depuis la **racine du projet** (ptitspas-app, où se trouve `docker-compose.yml`) :
|
||||
|
||||
```bash
|
||||
./scripts/reset-and-seed-db.sh
|
||||
```
|
||||
|
||||
Ce script : arrête les conteneurs, supprime le volume Postgres, redémarre la base (le schéma est recréé via `BDD.sql`), puis exécute `database/seed/03_seed_test_data.sql`. Tu obtiens un super_admin (`admin@ptits-pas.fr`) plus 9 comptes de test (1 admin, 1 gestionnaire, 2 AM, 5 parents) avec **mot de passe : `password`**. Idéal pour développer le ticket #92 (dashboard admin).
|
||||
|
||||
## Importation automatique des données de test
|
||||
|
||||
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- ============================================================
|
||||
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
||||
-- Aligné sur utilisateurs-test-complet.json
|
||||
-- Mot de passe universel : password (bcrypt)
|
||||
-- À exécuter après BDD.sql (init DB)
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Hash bcrypt pour "password" (10 rounds)
|
||||
|
||||
-- ========== UTILISATEURS (1 admin + 1 gestionnaire + 2 AM + 5 parents) ==========
|
||||
-- On garde admin@ptits-pas.fr (super_admin) déjà créé par BDD.sql
|
||||
|
||||
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||
VALUES
|
||||
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||
INSERT INTO parents (id_utilisateur, id_co_parent)
|
||||
VALUES
|
||||
('a0000005-0005-0005-0005-000000000005', 'a0000006-0006-0006-0006-000000000006'),
|
||||
('a0000006-0006-0006-0006-000000000006', 'a0000005-0005-0005-0005-000000000005'),
|
||||
('a0000007-0007-0007-0007-000000000007', NULL),
|
||||
('a0000008-0008-0008-0008-000000000008', NULL),
|
||||
('a0000009-0009-0009-0009-000000000009', NULL)
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ASSISTANTES MATERNELLES ==========
|
||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
||||
VALUES
|
||||
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS ==========
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||
VALUES
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||
-- Martin (Claire + Thomas) -> Emma, Noah, Léa
|
||||
INSERT INTO enfants_parents (id_parent, id_enfant)
|
||||
VALUES
|
||||
('a0000005-0005-0005-0005-000000000005', 'e0000001-0001-0001-0001-000000000001'),
|
||||
('a0000005-0005-0005-0005-000000000005', 'e0000002-0002-0002-0002-000000000002'),
|
||||
('a0000005-0005-0005-0005-000000000005', 'e0000003-0003-0003-0003-000000000003'),
|
||||
('a0000006-0006-0006-0006-000000000006', 'e0000001-0001-0001-0001-000000000001'),
|
||||
('a0000006-0006-0006-0006-000000000006', 'e0000002-0002-0002-0002-000000000002'),
|
||||
('a0000006-0006-0006-0006-000000000006', 'e0000003-0003-0003-0003-000000000003'),
|
||||
('a0000007-0007-0007-0007-000000000007', 'e0000004-0004-0004-0004-000000000004'),
|
||||
('a0000007-0007-0007-0007-000000000007', 'e0000005-0005-0005-0005-000000000005'),
|
||||
('a0000008-0008-0008-0008-000000000008', 'e0000004-0004-0004-0004-000000000004'),
|
||||
('a0000008-0008-0008-0008-000000000008', 'e0000005-0005-0005-0005-000000000005'),
|
||||
('a0000009-0009-0009-0009-000000000009', 'e0000006-0006-0006-0006-000000000006')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -55,6 +55,8 @@ services:
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
|
||||
NODE_ENV: ${NODE_ENV}
|
||||
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
|
||||
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
|
||||
depends_on:
|
||||
- database
|
||||
labels:
|
||||
|
||||
@@ -27,6 +27,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
||||
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
||||
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
||||
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
||||
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
||||
|
||||
### Administration (À créer)
|
||||
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Ticket #14 – Note pour modifications backend
|
||||
|
||||
**Contexte :** Première connexion admin → panneau Paramètres, déblocage après clic sur « Sauvegarder ». Le front appelle `POST /api/v1/configuration/setup/complete` au clic sur Sauvegarder.
|
||||
|
||||
## Problème
|
||||
|
||||
Erreur renvoyée par le back :
|
||||
`invalid input syntax for type uuid: "system"`
|
||||
|
||||
- Le controller fait `const userId = req.user?.id || 'system'` puis `markSetupCompleted(userId)`.
|
||||
- Le service `set()` fait `config.modifiePar = { id: userId }` ; la colonne `modifie_par` est une FK UUID vers `users`.
|
||||
- La chaîne `"system"` n’est pas un UUID valide → erreur PostgreSQL.
|
||||
|
||||
## Modifications à apporter au backend
|
||||
|
||||
**Option A – Accepter l’absence d’utilisateur (recommandé si la route peut être appelée sans JWT)**
|
||||
|
||||
1. **`config.controller.ts`** (route `completeSetup`)
|
||||
- Remplacer :
|
||||
`const userId = req.user?.id || 'system';`
|
||||
- Par :
|
||||
`const userId = req.user?.id ?? null;`
|
||||
|
||||
2. **`config.service.ts`** (`markSetupCompleted`)
|
||||
- Changer la signature :
|
||||
`async markSetupCompleted(userId: string | null): Promise<void>`
|
||||
- Et appeler :
|
||||
`await this.set('setup_completed', 'true', userId ?? undefined);`
|
||||
- Dans `set()`, ne pas remplir `modifiePar` quand `userId` est absent (déjà le cas si `if (userId)`).
|
||||
|
||||
**Option B – Imposer un utilisateur authentifié**
|
||||
|
||||
- Activer le guard JWT (et éventuellement RolesGuard) sur `POST /configuration/setup/complete` pour que `req.user` soit toujours défini, et garder `userId = req.user.id` (plus de fallback `'system'`).
|
||||
|
||||
---
|
||||
|
||||
Une fois le back modifié, le flux « Sauvegarder » → déblocage des panneaux fonctionne sans erreur.
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🔧 Documentation Technique - Configuration Système On-Premise
|
||||
|
||||
**Version** : 1.0
|
||||
**Date** : 25 Novembre 2025
|
||||
**Version** : 1.1
|
||||
**Date** : 9 Février 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Référence** : Architecture On-Premise
|
||||
|
||||
@@ -78,7 +78,7 @@ L'application P'titsPas est déployée **on-premise** chez différentes collecti
|
||||
2. **ConfigService** : Cache en mémoire + chiffrement
|
||||
3. **ConfigAPI** : Endpoints REST pour CRUD
|
||||
4. **Guard Setup** : Redirection forcée si config incomplète
|
||||
5. **Interface Admin** : Formulaire de configuration
|
||||
5. **Interface Admin** : Panneau Paramètres (3 sections) dans le dashboard, première config + accès permanent
|
||||
|
||||
---
|
||||
|
||||
@@ -304,93 +304,69 @@ export class ConfigService {
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SA as Super Admin
|
||||
participant App as Application
|
||||
participant Guard as SetupGuard
|
||||
participant Op as Opérateur
|
||||
participant App as Application (Dashboard)
|
||||
participant API as ConfigAPI
|
||||
participant DB as PostgreSQL
|
||||
participant SMTP as Serveur SMTP
|
||||
|
||||
SA->>App: Première connexion
|
||||
App->>Guard: Vérifier setup_completed
|
||||
Guard->>DB: SELECT valeur FROM configuration<br/>WHERE cle='setup_completed'
|
||||
DB-->>Guard: 'false'
|
||||
Op->>App: Première connexion (admin)
|
||||
App->>API: GET /configuration/setup/status
|
||||
API->>DB: setup_completed ?
|
||||
DB-->>API: false
|
||||
API-->>App: setupCompleted: false
|
||||
|
||||
Guard-->>App: Redirection forcée vers<br/>/admin/setup
|
||||
App->>App: Affiche panneau Configuration<br/>et bloque les autres onglets
|
||||
|
||||
SA->>SA: Remplit formulaire config<br/>(SMTP, app, sécurité)
|
||||
Op->>Op: Remplit les 3 sections<br/>(Email, Personnalisation, Avancé)
|
||||
|
||||
SA->>App: Clic "Tester la connexion SMTP"
|
||||
Op->>App: Clic "Tester la connexion SMTP"
|
||||
App->>API: POST /api/v1/configuration/test-smtp
|
||||
API->>SMTP: Test connexion
|
||||
|
||||
alt Test SMTP OK
|
||||
SMTP-->>API: ✅ Connexion réussie
|
||||
API->>SA: Envoi email de test
|
||||
API->>Op: Envoi email de test
|
||||
API-->>App: ✅ Test réussi
|
||||
App-->>SA: Message: "Email de test envoyé"
|
||||
App-->>Op: Message: "Email de test envoyé"
|
||||
else Test SMTP KO
|
||||
SMTP-->>API: ❌ Erreur connexion
|
||||
API-->>App: ❌ Erreur détaillée
|
||||
App-->>SA: Message: "Erreur: vérifiez les paramètres"
|
||||
App-->>Op: Message: "Erreur: vérifiez les paramètres"
|
||||
end
|
||||
|
||||
SA->>App: Clic "Sauvegarder"
|
||||
Op->>App: Clic "Sauvegarder et terminer la configuration"
|
||||
App->>API: PATCH /api/v1/configuration/bulk<br/>{smtp_host, smtp_port, ...}
|
||||
API->>DB: UPDATE configuration SET valeur=...
|
||||
API-->>App: OK
|
||||
|
||||
API->>DB: BEGIN TRANSACTION
|
||||
API->>DB: UPDATE configuration SET valeur=...<br/>FOR EACH key
|
||||
API->>DB: UPDATE configuration<br/>SET valeur='true'<br/>WHERE cle='setup_completed'
|
||||
API->>DB: COMMIT
|
||||
|
||||
App->>API: POST /api/v1/configuration/setup/complete
|
||||
API->>DB: SET setup_completed = true
|
||||
API->>API: Recharger cache ConfigService
|
||||
API-->>App: OK
|
||||
|
||||
API-->>App: ✅ Configuration sauvegardée
|
||||
App-->>SA: Redirection vers /admin/dashboard
|
||||
|
||||
SA->>App: Accès complet à l'application
|
||||
App->>App: Débloque la navigation<br/>Message succès
|
||||
Op->>App: Accès complet au dashboard
|
||||
```
|
||||
|
||||
### Étapes détaillées
|
||||
|
||||
#### 1. Détection configuration incomplète
|
||||
|
||||
**Guard** : `SetupGuard` (NestJS)
|
||||
**Backend** : Le `SetupGuard` (NestJS) vérifie `setup_completed`. Si false, il autorise l’accès au dashboard et aux APIs configuration (pas de redirection vers une page dédiée). Le **frontend** appelle `GET /configuration/setup/status` au chargement du dashboard admin ; si `setupCompleted === false`, il affiche directement le **panneau Paramètres** et désactive les autres onglets jusqu’à sauvegarde.
|
||||
|
||||
**Guard** (exemple) : exemption des routes login + dashboard + APIs configuration.
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class SetupGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const setupCompleted = this.configService.get('setup_completed', false);
|
||||
|
||||
// Exemptions
|
||||
const exemptedRoutes = ['/auth/login', '/admin/setup', '/api/v1/configuration'];
|
||||
if (exemptedRoutes.some(route => request.url.includes(route))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si setup non complété, bloquer
|
||||
if (!setupCompleted) {
|
||||
throw new HttpException(
|
||||
'Configuration initiale requise',
|
||||
HttpStatus.TEMPORARY_REDIRECT,
|
||||
{ location: '/admin/setup' }
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Exemptions : /auth/login, /api/v1/configuration, routes dashboard admin
|
||||
// Si setup non complété : pas de redirection HTTP ; le frontend gère l’affichage du panneau Config et le blocage des onglets.
|
||||
```
|
||||
|
||||
#### 2. Formulaire Setup (Frontend)
|
||||
#### 2. Panneau Paramètres (Frontend)
|
||||
|
||||
**3 onglets** :
|
||||
**Une seule page avec 3 sections** (blocs successifs, pas d’onglets dans le formulaire) :
|
||||
|
||||
##### Onglet 1 : Configuration Email 📧
|
||||
##### Section 1 : Configuration Email 📧
|
||||
|
||||
| Champ | Type | Valeur par défaut | Obligatoire |
|
||||
|-------|------|-------------------|-------------|
|
||||
@@ -405,7 +381,7 @@ export class SetupGuard implements CanActivate {
|
||||
|
||||
**Bouton** : "🧪 Tester la connexion SMTP"
|
||||
|
||||
##### Onglet 2 : Personnalisation 🎨
|
||||
##### Section 2 : Personnalisation 🎨
|
||||
|
||||
| Champ | Type | Valeur par défaut | Obligatoire |
|
||||
|-------|------|-------------------|-------------|
|
||||
@@ -413,7 +389,7 @@ export class SetupGuard implements CanActivate {
|
||||
| URL de l'application | URL | `https://app.ptits-pas.fr` | ✅ |
|
||||
| Logo | File (PNG/JPG) | Logo par défaut | ❌ |
|
||||
|
||||
##### Onglet 3 : Paramètres avancés ⚙️
|
||||
##### Section 3 : Paramètres avancés ⚙️
|
||||
|
||||
| Champ | Type | Valeur par défaut | Obligatoire |
|
||||
|-------|------|-------------------|-------------|
|
||||
@@ -421,7 +397,7 @@ export class SetupGuard implements CanActivate {
|
||||
| Durée session JWT (heures) | Number | `24` | ✅ |
|
||||
| Taille max upload (MB) | Number | `5` | ✅ |
|
||||
|
||||
**Bouton** : "💾 Sauvegarder et terminer la configuration"
|
||||
**Bouton** : "💾 Sauvegarder et terminer la configuration" (première config) ou "💾 Enregistrer" (accès permanent).
|
||||
|
||||
---
|
||||
|
||||
@@ -536,60 +512,39 @@ Content-Type: application/json
|
||||
|
||||
## 💻 Interface Admin
|
||||
|
||||
### Écran Setup Initial
|
||||
### Panneau Paramètres / Configuration (unique)
|
||||
|
||||
Un **seul panneau** dans le dashboard admin, avec **3 sections** affichées sur une même page (défilement si besoin). Pas d’onglets dans le formulaire.
|
||||
|
||||
- **Première configuration** (au déploiement, `setup_completed === false`) : l’opérateur arrive sur le dashboard ; le panneau Configuration est affiché par défaut et les **autres onglets sont bloqués** jusqu’à clic sur « Sauvegarder et terminer la configuration » (PATCH bulk + POST setup/complete).
|
||||
- **Accès permanent** : même panneau accessible via l’onglet « Configuration » / « Paramètres » du dashboard ; pas de blocage, simple modification et enregistrement (PATCH bulk).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🚀 Configuration Initiale - P'titsPas │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Bienvenue ! Configurez votre installation P'titsPas │
|
||||
│ │
|
||||
│ [ 📧 Email ] [ 🎨 Personnalisation ] [ ⚙️ Avancé ] │
|
||||
│ Dashboard Admin [ Gestionnaires ] [ Parents ] ... │
|
||||
│ [ Configuration ] ← onglet actif │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📧 Configuration Email (SMTP) │
|
||||
│ Serveur SMTP * [_________________________________] │
|
||||
│ Port * [____] Sécurité [▼] ☐ Auth requise │
|
||||
│ Utilisateur [__________] Mot de passe [__________] │
|
||||
│ Nom expéditeur * [__________] Email * [__________] │
|
||||
│ [ 🧪 Tester la connexion SMTP ] │
|
||||
│ │
|
||||
│ Serveur SMTP * │
|
||||
│ [_____________________________________________] │
|
||||
│ Ex: mail.mairie-bezons.fr, smtp.gmail.com │
|
||||
│ │
|
||||
│ Port SMTP * │
|
||||
│ [_____] 25 (standard), 465 (SSL), 587 (STARTTLS) │
|
||||
│ │
|
||||
│ Sécurité * │
|
||||
│ [ ▼ Aucune ] STARTTLS SSL/TLS │
|
||||
│ │
|
||||
│ ☐ Authentification requise │
|
||||
│ │
|
||||
│ Utilisateur SMTP │
|
||||
│ [_____________________________________________] │
|
||||
│ │
|
||||
│ Mot de passe SMTP │
|
||||
│ [_____________________________________________] │
|
||||
│ │
|
||||
│ Nom de l'expéditeur * │
|
||||
│ [_____________________________________________] │
|
||||
│ Ex: P'titsPas - Mairie de Bezons │
|
||||
│ │
|
||||
│ Email expéditeur * │
|
||||
│ [_____________________________________________] │
|
||||
│ Ex: noreply@mairie-bezons.fr │
|
||||
│ │
|
||||
│ [ 🧪 Tester la connexion SMTP ] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [ ← Précédent ] [ Suivant → ] │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ 🎨 Personnalisation │
|
||||
│ Nom application * [__________] URL * [__________] │
|
||||
│ Logo [ Choisir un fichier ] │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ ⚙️ Paramètres avancés │
|
||||
│ Durée token MDP (jours) [__] JWT (h) [__] Upload MB [__] │
|
||||
│ │
|
||||
│ [ 💾 Sauvegarder et terminer la configuration ] │
|
||||
│ (ou « Enregistrer » si config déjà complétée) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Écran Paramètres (accès permanent)
|
||||
|
||||
Identique au Setup Initial, mais accessible depuis le menu admin :
|
||||
- Menu Admin → Paramètres → Configuration Système
|
||||
|
||||
---
|
||||
|
||||
## 📋 Exemples de configuration
|
||||
@@ -706,7 +661,7 @@ PORT=3000
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 25 Novembre 2025
|
||||
**Version** : 1.0
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.1
|
||||
**Statut** : ✅ Document validé
|
||||
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||
|
||||
**Version** : 1.0
|
||||
**Date** : 25 Novembre 2025
|
||||
**Version** : 1.4
|
||||
**Date** : 9 Février 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Estimation totale** : ~173h
|
||||
**Estimation totale** : ~184h
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Liste des tickets Gitea
|
||||
|
||||
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
|
||||
|
||||
| Gitea # | Titre (dépôt) | Statut |
|
||||
|--------|----------------|--------|
|
||||
| 3 | [BDD] Ajout champs manquants conformité CDC | ✅ Fermé |
|
||||
| 4 | [BDD] Ajout table/champ présentation dossier parent | ✅ Fermé |
|
||||
| 5 | [BDD] Ajout gestion tokens création mot de passe | ✅ Fermé |
|
||||
| 6 | [BDD] Ajout champ genre obligatoire enfants | ✅ Fermé |
|
||||
| 7 | [BDD] Supprimer champs obsolètes | ✅ Fermé |
|
||||
| 8 | [BDD] Table configuration système | ✅ Fermé |
|
||||
| 9 | [BDD] Tables documents légaux & acceptations | ✅ Fermé |
|
||||
| 10 | [Backend] Service Configuration | ✅ Fermé |
|
||||
| 11 | [Backend] API Configuration | ✅ Fermé |
|
||||
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
||||
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
||||
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
||||
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||
| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
|
||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
|
||||
|
||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||
|
||||
---
|
||||
|
||||
@@ -16,17 +43,17 @@
|
||||
| **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) |
|
||||
| **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) |
|
||||
| **P2** | 18 tickets | ~50h | Backend métier |
|
||||
| **P3** | 17 tickets | ~52h | Frontend |
|
||||
| **P3** | 22 tickets | ~71h | Frontend |
|
||||
| **P4** | 4 tickets | ~24h | Tests & Documentation |
|
||||
| **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC |
|
||||
| **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy |
|
||||
| **TOTAL** | **61 tickets** | **~173h** | |
|
||||
| **TOTAL** | **65 tickets** | **~184h** | |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRIORITÉ 0 : Amendements Base de Données (BLOQUANT)
|
||||
|
||||
### Ticket #1 : [BDD] Ajout champs manquants conformité CDC
|
||||
### Ticket #3 : [BDD] Ajout champs manquants conformité CDC
|
||||
**Estimation** : 1h
|
||||
**Labels** : `bdd`, `p0-bloquant`, `cdc`
|
||||
|
||||
@@ -44,7 +71,7 @@ Ajouter les champs manquants dans la base de données pour être conforme au Cah
|
||||
|
||||
---
|
||||
|
||||
### Ticket #2 : [BDD] Ajout table/champ présentation dossier parent
|
||||
### Ticket #4 : [BDD] Ajout table/champ présentation dossier parent
|
||||
**Estimation** : 30min
|
||||
**Labels** : `bdd`, `p0-bloquant`, `cdc`
|
||||
|
||||
@@ -58,7 +85,7 @@ Ajouter un champ pour stocker la présentation du dossier parent (étape 4 de l'
|
||||
|
||||
---
|
||||
|
||||
### Ticket #3 : [BDD] Ajout gestion tokens création mot de passe ✅
|
||||
### Ticket #5 : [BDD] Ajout gestion tokens création mot de passe ✅
|
||||
**Estimation** : 30min
|
||||
**Labels** : `bdd`, `p0-bloquant`, `security`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-28)
|
||||
@@ -74,7 +101,7 @@ Ajouter les champs nécessaires pour gérer les tokens de création de mot de pa
|
||||
|
||||
---
|
||||
|
||||
### Ticket #4 : [BDD] Ajout champ genre obligatoire enfants ✅
|
||||
### Ticket #6 : [BDD] Ajout champ genre obligatoire enfants ✅
|
||||
**Estimation** : 30min
|
||||
**Labels** : `bdd`, `p0-bloquant`, `cdc`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-28)
|
||||
@@ -89,7 +116,7 @@ Ajouter le champ `genre` obligatoire (H/F) dans la table `enfants`.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #5 : [BDD] Supprimer champs obsolètes
|
||||
### Ticket #7 : [BDD] Supprimer champs obsolètes
|
||||
**Estimation** : 30min
|
||||
**Labels** : `bdd`, `p0-bloquant`, `cleanup`
|
||||
|
||||
@@ -106,7 +133,7 @@ Supprimer les champs obsolètes identifiés lors de l'audit.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #6 : [BDD] Table configuration système
|
||||
### Ticket #8 : [BDD] Table configuration système
|
||||
**Estimation** : 1h
|
||||
**Labels** : `bdd`, `p0-bloquant`, `on-premise`
|
||||
|
||||
@@ -124,7 +151,7 @@ Créer la table `configuration` pour stocker les paramètres système (SMTP, app
|
||||
|
||||
---
|
||||
|
||||
### Ticket #7 : [BDD] Tables documents légaux & acceptations ✅
|
||||
### Ticket #9 : [BDD] Tables documents légaux & acceptations ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `bdd`, `p0-bloquant`, `rgpd`, `juridique`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-30 - Ticket #68 sur Gitea)
|
||||
@@ -147,7 +174,7 @@ Créer les tables pour gérer les versions des documents légaux (CGU/Privacy) e
|
||||
|
||||
## 🟠 PRIORITÉ 1 : Configuration Système (BLOQUANT)
|
||||
|
||||
### Ticket #8 : [Backend] Service Configuration
|
||||
### Ticket #10 : [Backend] Service Configuration
|
||||
**Estimation** : 4h
|
||||
**Labels** : `backend`, `p1-bloquant`, `on-premise`
|
||||
|
||||
@@ -168,7 +195,7 @@ Créer le service de configuration avec cache en mémoire et chiffrement AES-256
|
||||
|
||||
---
|
||||
|
||||
### Ticket #9 : [Backend] API Configuration
|
||||
### Ticket #11 : [Backend] API Configuration
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p1-bloquant`, `on-premise`
|
||||
|
||||
@@ -187,26 +214,27 @@ Créer les endpoints REST pour gérer la configuration système.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #10 : [Backend] Guard Configuration Initiale
|
||||
### Ticket #12 : [Backend] Guard Configuration Initiale
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p1-bloquant`, `on-premise`
|
||||
|
||||
**Description** :
|
||||
Créer un Guard/Middleware qui détecte si la configuration initiale est incomplète et force la redirection.
|
||||
Créer un Guard/Middleware qui détecte si la configuration initiale est incomplète. Le frontend affiche alors directement le panneau Configuration du dashboard et bloque la navigation jusqu'à sauvegarde (pas de page dédiée `/admin/setup`).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Créer `SetupGuard`
|
||||
- [ ] Vérifier `setup_completed` dans ConfigService
|
||||
- [ ] Redirection forcée vers `/admin/setup` si false
|
||||
- [ ] Exemption pour routes publiques (login, register)
|
||||
- [ ] Exemption pour route `/admin/setup`
|
||||
- [ ] Si false : autoriser accès au dashboard et aux APIs configuration (le frontend gère l’affichage du panneau Config et le blocage des onglets)
|
||||
- [ ] Exemption pour routes publiques (login, register) et pour les APIs `/api/v1/configuration`
|
||||
- [ ] Tests unitaires
|
||||
|
||||
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial)
|
||||
|
||||
*Issue Gitea #86 fermée en doublon ; ce ticket (#12) est la référence.*
|
||||
|
||||
---
|
||||
|
||||
### Ticket #11 : [Backend] Adaptation MailService pour config dynamique
|
||||
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p1-bloquant`, `on-premise`, `email`
|
||||
|
||||
@@ -224,44 +252,45 @@ Adapter le service email pour utiliser la configuration dynamique depuis la BDD
|
||||
|
||||
---
|
||||
|
||||
### Ticket #12 : [Frontend] Écran Configuration Initiale (Setup Wizard)
|
||||
**Estimation** : 6h
|
||||
### Ticket #14 : [Frontend] Panneau Paramètres / Configuration (première config + accès permanent)
|
||||
**Estimation** : 5h
|
||||
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
|
||||
|
||||
**Description** :
|
||||
Créer l'écran de configuration initiale (Setup Wizard) accessible à la première connexion du super admin.
|
||||
Un seul panneau **Paramètres / Configuration** dans le dashboard admin, avec **3 sections** sur une même page (pas d’onglets dédiés au formulaire : Email, Personnalisation, Avancé). Utilisé à la fois pour la **première configuration** (au déploiement, par un opérateur) et pour l’**accès permanent** (menu ou onglet Configuration). Lorsque `setup_completed` est false, le dashboard affiche directement ce panneau et **bloque la navigation** (autres onglets désactivés) jusqu’à sauvegarde.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Page `/admin/setup` (accessible uniquement si config incomplète)
|
||||
- [ ] Formulaire multi-onglets (Email / Application / Avancé)
|
||||
- [ ] Onglet 1 : Configuration Email (SMTP, auth, expéditeur)
|
||||
- [ ] Onglet 2 : Personnalisation (nom app, URL, logo)
|
||||
- [ ] Onglet 3 : Paramètres avancés (durées tokens, upload max)
|
||||
- [ ] Panneau Configuration dans le dashboard admin (onglet ou entrée de menu dédiée)
|
||||
- [ ] Une seule page avec 3 sections : **Email (SMTP)** ; **Personnalisation** (nom app, URL, logo) ; **Avancé** (durées token MDP, JWT, taille max upload)
|
||||
- [ ] Bouton "Tester la connexion SMTP" (appel API + feedback)
|
||||
- [ ] Validation côté client
|
||||
- [ ] Sauvegarde (appel API `PATCH /configuration/bulk`)
|
||||
- [ ] Message succès + redirection dashboard
|
||||
- [ ] Sauvegarde : `PATCH /configuration/bulk` puis `POST /configuration/setup/complete` si première config
|
||||
- [ ] Si `setup_completed` false au chargement : afficher ce panneau par défaut et bloquer les autres onglets jusqu’à sauvegarde
|
||||
- [ ] Message succès ; après première config, déblocage de la navigation
|
||||
|
||||
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
|
||||
|
||||
*Issue Gitea #87 fermée en doublon de #14.*
|
||||
|
||||
---
|
||||
|
||||
### Ticket #13 : [Frontend] Écran Paramètres (accès permanent)
|
||||
**Estimation** : 2h
|
||||
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
|
||||
**Estimation** : 1h
|
||||
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
|
||||
|
||||
**Description** :
|
||||
Créer l'écran de paramètres accessible depuis le menu admin (même interface que Setup Wizard).
|
||||
S’assurer que le panneau Paramètres (décrit en #14) est accessible en permanence depuis le dashboard admin (onglet « Configuration » ou « Paramètres »). Même interface que pour la première config ; affichage des valeurs actuelles, modification et sauvegarde sans blocage de navigation.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Page `/admin/parametres` (accessible depuis menu admin)
|
||||
- [ ] Même interface que Setup Wizard
|
||||
- [ ] Affichage valeurs actuelles
|
||||
- [ ] Modification et sauvegarde
|
||||
- [ ] Onglet ou entrée menu « Configuration » / « Paramètres » dans le dashboard admin pointant vers le même panneau que #14
|
||||
- [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie)
|
||||
- [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete`
|
||||
|
||||
*Issue Gitea #88 fermée en doublon ; ce ticket (#15) est la référence.*
|
||||
|
||||
---
|
||||
|
||||
### Ticket #14 : [Doc] Documentation configuration on-premise
|
||||
### Ticket #16 : [Doc] Documentation configuration on-premise
|
||||
**Estimation** : 2h
|
||||
**Labels** : `documentation`, `p1-bloquant`, `on-premise`
|
||||
|
||||
@@ -280,9 +309,14 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
|
||||
|
||||
---
|
||||
|
||||
### Ticket #86 / #88 : Doublons fermés
|
||||
*#86* fermé en doublon de **#12** (Guard). *#88* fermé en doublon de **#15** (Intégration panneau). Voir les tickets #12, #14 et #15 pour le travail à faire.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
||||
|
||||
### Ticket #15 : [Backend] API Création gestionnaire
|
||||
### Ticket #17 : [Backend] API Création gestionnaire
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `auth`
|
||||
|
||||
@@ -302,7 +336,7 @@ Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #16 : [Backend] API Inscription Parent (étape 1 - Parent 1)
|
||||
### Ticket #18 : [Backend] API Inscription Parent (étape 1 - Parent 1)
|
||||
**Estimation** : 4h
|
||||
**Labels** : `backend`, `p2`, `auth`, `cdc`
|
||||
|
||||
@@ -322,7 +356,7 @@ Créer l'endpoint d'inscription Parent (étape 1/6 : informations Parent 1).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #17 : [Backend] API Inscription Parent (étape 2 - Parent 2)
|
||||
### Ticket #19 : [Backend] API Inscription Parent (étape 2 - Parent 2)
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `auth`, `cdc`
|
||||
|
||||
@@ -409,7 +443,7 @@ Finalisation de l'inscription parent (présentation, CGU, récapitulatif - inté
|
||||
|
||||
---
|
||||
|
||||
### Ticket #22 : [Backend] API Création mot de passe
|
||||
### Ticket #24 : [Backend] API Création mot de passe
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
|
||||
@@ -428,7 +462,7 @@ Créer les endpoints pour permettre aux utilisateurs de créer leur mot de passe
|
||||
|
||||
---
|
||||
|
||||
### Ticket #23 : [Backend] API Liste comptes en attente
|
||||
### Ticket #25 : [Backend] API Liste comptes en attente
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `gestionnaire`
|
||||
|
||||
@@ -446,7 +480,7 @@ Créer les endpoints pour lister les comptes en attente de validation.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #24 : [Backend] API Validation/Refus comptes
|
||||
### Ticket #26 : [Backend] API Validation/Refus comptes
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `gestionnaire`
|
||||
|
||||
@@ -464,7 +498,7 @@ Créer les endpoints pour valider ou refuser les comptes en attente.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #25 : [Backend] Service Email - Installation Nodemailer
|
||||
### Ticket #27 : [Backend] Service Email - Installation Nodemailer
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
|
||||
@@ -479,7 +513,7 @@ Installer et configurer Nodemailer pour l'envoi d'emails.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #26 : [Backend] Templates Email - Validation
|
||||
### Ticket #28 : [Backend] Templates Email - Validation
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
|
||||
@@ -500,7 +534,7 @@ Créer les templates d'emails pour la validation des comptes (avec lien créatio
|
||||
|
||||
---
|
||||
|
||||
### Ticket #27 : [Backend] Templates Email - Refus
|
||||
### Ticket #29 : [Backend] Templates Email - Refus
|
||||
**Estimation** : 1h
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
|
||||
@@ -515,7 +549,7 @@ Créer le template d'email pour le refus de compte.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #28 : [Backend] Connexion - Vérification statut
|
||||
### Ticket #30 : [Backend] Connexion - Vérification statut
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `auth`
|
||||
|
||||
@@ -533,7 +567,7 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
|
||||
|
||||
---
|
||||
|
||||
### Ticket #29 : [Backend] Changement MDP obligatoire première connexion
|
||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
|
||||
@@ -548,7 +582,7 @@ Implémenter le changement de mot de passe obligatoire pour les gestionnaires/ad
|
||||
|
||||
---
|
||||
|
||||
### Ticket #30 : [Backend] Service Documents Légaux
|
||||
### Ticket #32 : [Backend] Service Documents Légaux
|
||||
**Estimation** : 4h
|
||||
**Labels** : `backend`, `p2`, `juridique`, `rgpd`
|
||||
|
||||
@@ -570,7 +604,7 @@ Créer le service de gestion des documents légaux (CGU/Privacy) avec versioning
|
||||
|
||||
---
|
||||
|
||||
### Ticket #31 : [Backend] API Documents Légaux
|
||||
### Ticket #33 : [Backend] API Documents Légaux
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `juridique`, `rgpd`
|
||||
|
||||
@@ -590,7 +624,7 @@ Créer les endpoints REST pour gérer les documents légaux.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #32 : [Backend] Traçabilité acceptations documents
|
||||
### Ticket #34 : [Backend] Traçabilité acceptations documents
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `rgpd`
|
||||
|
||||
@@ -609,7 +643,7 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
|
||||
|
||||
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
|
||||
|
||||
### Ticket #33 : [Frontend] Écran Création Gestionnaire
|
||||
### Ticket #35 : [Frontend] Écran Création Gestionnaire
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`
|
||||
|
||||
@@ -624,14 +658,6 @@ Créer l'écran de création de gestionnaire (super admin uniquement).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #34 : [Réservé - Non utilisé]
|
||||
|
||||
---
|
||||
|
||||
### Ticket #35 : [Réservé - Non utilisé]
|
||||
|
||||
---
|
||||
|
||||
### Ticket #36 : [Frontend] Inscription Parent - Étape 1 (Parent 1) ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
@@ -668,85 +694,90 @@ Créer le formulaire d'inscription parent - étape 2/6 (informations Parent 2 op
|
||||
|
||||
---
|
||||
|
||||
### Ticket #38 : [Frontend] Inscription Parent - Étape 3 (Enfants)
|
||||
**Estimation** : 4h
|
||||
### Ticket #38 : [Frontend] Inscription Parent - Étape 3 (Enfants) ✅
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`, `upload`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Créer le formulaire d'inscription parent - étape 3/6 (informations enfants).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Question "Enfant déjà né ?"
|
||||
- [ ] Formulaire enfant (prénom, date, genre H/F obligatoire)
|
||||
- [ ] Upload photo (si né)
|
||||
- [ ] Validation taille (5MB)
|
||||
- [ ] Bouton "Ajouter un autre enfant"
|
||||
- [ ] Navigation vers étape 4
|
||||
- [x] Question "Enfant déjà né ?"
|
||||
- [x] Formulaire enfant (prénom, date, genre H/F obligatoire)
|
||||
- [x] Upload photo (si né)
|
||||
- [x] Validation taille (5MB)
|
||||
- [x] Bouton "Ajouter un autre enfant"
|
||||
- [x] Navigation vers étape 4
|
||||
|
||||
---
|
||||
|
||||
### Ticket #39 : [Frontend] Inscription Parent - Étapes 4-6 (Finalisation)
|
||||
**Estimation** : 4h
|
||||
### Ticket #39 : [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) ✅
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Créer les étapes finales de l'inscription parent (présentation, CGU, récapitulatif).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Étape 4 : Textarea présentation
|
||||
- [ ] Étape 5 : Checkbox CGU + liens PDF
|
||||
- [ ] Étape 6 : Récapitulatif complet
|
||||
- [ ] Bouton "Modifier" / "Valider"
|
||||
- [ ] Appel API final
|
||||
- [ ] Message confirmation
|
||||
- [x] Étape 4 : Textarea présentation
|
||||
- [x] Étape 5 : Checkbox CGU + liens PDF
|
||||
- [x] Étape 6 : Récapitulatif complet
|
||||
- [x] Bouton "Modifier" / "Valider"
|
||||
- [x] Appel API final
|
||||
- [x] Message confirmation
|
||||
|
||||
---
|
||||
|
||||
### Ticket #40 : [Frontend] Inscription AM - Panneau 1 (Identité)
|
||||
**Estimation** : 3h
|
||||
### Ticket #40 : [Frontend] Inscription AM - Panneau 1 (Identité) ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`, `upload`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Créer le formulaire d'inscription AM - panneau 1/5 (identité).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Formulaire identité
|
||||
- [ ] Upload photo
|
||||
- [ ] Checkbox consentement photo
|
||||
- [ ] Pas de champ mot de passe
|
||||
- [ ] Navigation vers panneau 2
|
||||
- [x] Formulaire identité
|
||||
- [x] Upload photo
|
||||
- [x] Checkbox consentement photo
|
||||
- [x] Pas de champ mot de passe
|
||||
- [x] Navigation vers panneau 2
|
||||
|
||||
---
|
||||
|
||||
### Ticket #41 : [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
||||
**Estimation** : 3h
|
||||
### Ticket #41 : [Frontend] Inscription AM - Panneau 2 (Infos pro) ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Créer le formulaire d'inscription AM - panneau 2/5 (informations professionnelles).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Formulaire infos pro
|
||||
- [ ] Champ NIR (15 chiffres, validation)
|
||||
- [ ] Date/lieu naissance
|
||||
- [ ] Agrément + date obtention
|
||||
- [ ] Navigation vers présentation
|
||||
- [x] Formulaire infos pro
|
||||
- [x] Champ NIR (15 chiffres, validation)
|
||||
- [x] Date/lieu naissance
|
||||
- [x] Agrément + date obtention
|
||||
- [x] Navigation vers présentation
|
||||
|
||||
---
|
||||
|
||||
### Ticket #42 : [Frontend] Inscription AM - Finalisation
|
||||
**Estimation** : 3h
|
||||
### Ticket #42 : [Frontend] Inscription AM - Finalisation ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Créer les étapes finales de l'inscription AM (présentation, CGU, récapitulatif).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Textarea présentation
|
||||
- [ ] Checkbox CGU + liens PDF
|
||||
- [ ] Récapitulatif (NIR masqué)
|
||||
- [ ] Appel API final
|
||||
- [ ] Message confirmation
|
||||
- [x] Textarea présentation
|
||||
- [x] Checkbox CGU + liens PDF
|
||||
- [x] Récapitulatif (NIR masqué)
|
||||
- [x] Appel API final
|
||||
- [x] Message confirmation
|
||||
|
||||
---
|
||||
|
||||
@@ -863,6 +894,30 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
|
||||
**Estimation** : 8h
|
||||
**Labels** : `frontend`, `p3`, `admin`
|
||||
|
||||
**Description** :
|
||||
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
||||
|
||||
**Fichiers concernés** :
|
||||
- `gestionnaire_management_widget.dart` — liste actuellement 5 cartes "Dupont" en dur
|
||||
- `parent_managmant_widget.dart` — 2 parents simulés
|
||||
- `assistante_maternelle_management_widget.dart` — 2 AM simulées
|
||||
|
||||
**Tâches** :
|
||||
- [ ] S'assurer que les endpoints backend existent (liste users par rôle)
|
||||
- [ ] Onglet Gestionnaires : appel API, affichage dynamique, recherche, lien "Créer gestionnaire"
|
||||
- [ ] Onglet Parents : appel API, affichage dynamique, recherche/filtres, actions Voir/Modifier/Valider/Refuser
|
||||
- [ ] Onglet Assistantes maternelles : appel API, affichage dynamique, filtres, actions
|
||||
- [ ] Onglet Administrateurs : liste ou placeholder documenté
|
||||
- [ ] Gestion états (chargement, erreur, liste vide) et rafraîchissement après actions
|
||||
|
||||
**Références** : #44, #45, #46 (dashboard Gestionnaire), #25, #26 (API liste/validation), #17, #35 (création gestionnaire)
|
||||
|
||||
---
|
||||
|
||||
### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription
|
||||
**Estimation** : 2h
|
||||
**Labels** : `frontend`, `p3`, `juridique`
|
||||
@@ -892,6 +947,77 @@ Créer l'écran de consultation des logs système (optionnel pour v1.1).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #78 : [Frontend] Refonte Infrastructure Formulaires Multi-modes ✅
|
||||
**Estimation** : 8h
|
||||
**Labels** : `frontend`, `p3`, `refactoring`, `ux`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-01-27)
|
||||
|
||||
**Description** :
|
||||
Créer une infrastructure générique pour gérer les formulaires en modes multiples (Editable / Readonly / Mobile / Desktop) afin d'harmoniser l'UI et faciliter la maintenance.
|
||||
|
||||
**Tâches** :
|
||||
- [x] Créer `DisplayConfig` et `DisplayMode` (editable, readonly)
|
||||
- [x] Créer `FormFieldWrapper` pour affichage uniforme readonly
|
||||
- [x] Migrer `PersonalInfoFormScreen` (Parents/AM)
|
||||
- [x] Migrer `ChildCardWidget` (Enfants)
|
||||
- [x] Migrer `ProfessionalInfoFormScreen` (AM)
|
||||
- [x] Migrer `PresentationFormScreen` (Motivation/CGU)
|
||||
- [x] Implémenter layout "Vintage" (2:1) pour Desktop Readonly
|
||||
- [x] Implémenter layout adaptatif pour Mobile Readonly
|
||||
- [x] Harmoniser styles (champs beiges, polices)
|
||||
|
||||
---
|
||||
|
||||
### Ticket #79 : [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `frontend`, `p3`, `refactoring`, `cdc`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Renommage complet de "Nanny" en "AM" dans le frontend pour cohérence avec le CDC.
|
||||
|
||||
**Tâches** :
|
||||
- [x] Modèles : nanny_registration_data.dart -> am_registration_data.dart
|
||||
- [x] Écrans : nanny_register_*.dart -> am_register_*.dart
|
||||
- [x] Routes : /nanny-register -> /am-register
|
||||
- [x] Suppression fichiers obsolètes
|
||||
|
||||
---
|
||||
|
||||
### Ticket #81 : [Frontend] Corrections suite refactoring widgets ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `frontend`, `p3`, `bugfix`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
|
||||
|
||||
**Description** :
|
||||
Corrections et ajustements suite au refactoring des widgets.
|
||||
|
||||
**Corrections :**
|
||||
- [x] Erreurs compilation (updateParent1/2, updateIdentityInfo)
|
||||
- [x] Routes obsolètes supprimées
|
||||
- [x] Toggles Parent Step 2 (2 côte à côte)
|
||||
- [x] Positionnement éléments dans cartes
|
||||
|
||||
---
|
||||
|
||||
### Ticket #83 : [Frontend] Adapter RegisterChoiceScreen pour mobile ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `responsive`, `ux`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-01-27)
|
||||
|
||||
**Description** :
|
||||
Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et cohérence avec les autres écrans.
|
||||
|
||||
**Tâches :**
|
||||
- [x] Implémentation responsive avec LayoutBuilder (mobile < 900px)
|
||||
- [x] Mode mobile : titre au-dessus, carte pleine largeur (ratio 2/3), boutons verticaux
|
||||
- [x] Mode desktop : chevron haut-gauche, layout texte/carte côte à côte
|
||||
- [x] Extraction logique carte dans ChoiceCardWidget réutilisable
|
||||
- [x] Bouton "Précédent" mobile avec CustomNavigationButton + HoverReliefWidget
|
||||
- [x] Tailles icônes augmentées (140px mobile, 170px desktop)
|
||||
|
||||
---
|
||||
|
||||
## 🔵 PRIORITÉ 4 : Tests & Documentation
|
||||
|
||||
### Ticket #52 : [Tests] Tests unitaires Backend
|
||||
@@ -909,7 +1035,7 @@ Créer les tests unitaires pour tous les services et controllers backend.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #50 : [Tests] Tests intégration Backend
|
||||
### Ticket #53 : [Tests] Tests intégration Backend
|
||||
**Estimation** : 5h
|
||||
**Labels** : `tests`, `p4`, `backend`
|
||||
|
||||
@@ -924,7 +1050,7 @@ Créer les tests d'intégration pour les workflows complets.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #51 : [Tests] Tests E2E Frontend
|
||||
### Ticket #54 : [Tests] Tests E2E Frontend
|
||||
**Estimation** : 8h
|
||||
**Labels** : `tests`, `p4`, `frontend`
|
||||
|
||||
@@ -940,7 +1066,7 @@ Créer les tests end-to-end pour les parcours utilisateurs.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #52 : [Doc] Documentation API OpenAPI/Swagger
|
||||
### Ticket #55 : [Doc] Documentation API OpenAPI/Swagger
|
||||
**Estimation** : 3h
|
||||
**Labels** : `documentation`, `p4`, `api`
|
||||
|
||||
@@ -957,7 +1083,7 @@ Générer et documenter l'API avec Swagger/OpenAPI.
|
||||
|
||||
## ⚠️ CRITIQUES : Upload, Emails, Infra, Doc
|
||||
|
||||
### Ticket #53 : [Backend] Service Upload & Stockage fichiers
|
||||
### Ticket #56 : [Backend] Service Upload & Stockage fichiers
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `critique`, `upload`
|
||||
|
||||
@@ -988,7 +1114,7 @@ Créer l'endpoint sécurisé pour télécharger les photos.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #55 : [Backend] Service Logging (Winston)
|
||||
### Ticket #58 : [Backend] Service Logging (Winston)
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `critique`, `monitoring`
|
||||
|
||||
@@ -1007,7 +1133,7 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
|
||||
|
||||
---
|
||||
|
||||
### Ticket #56 : [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
||||
|
||||
@@ -1025,7 +1151,7 @@ Créer un écran pour consulter les logs depuis l'interface admin (optionnel Pha
|
||||
|
||||
---
|
||||
|
||||
### Ticket #57 : [Infra] Volume Docker pour uploads
|
||||
### Ticket #59 : [Infra] Volume Docker pour uploads
|
||||
**Estimation** : 30min
|
||||
**Labels** : `infra`, `critique`, `docker`
|
||||
|
||||
@@ -1039,7 +1165,7 @@ Ajouter un volume Docker pour persister les fichiers uploadés.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #58 : [Infra] Volume Docker pour documents légaux
|
||||
### Ticket #60 : [Infra] Volume Docker pour documents légaux
|
||||
**Estimation** : 30min
|
||||
**Labels** : `infra`, `critique`, `docker`
|
||||
|
||||
@@ -1053,7 +1179,7 @@ Ajouter un volume Docker pour persister les documents légaux (CGU/Privacy).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #59 : [Doc] Guide installation & configuration
|
||||
### Ticket #61 : [Doc] Guide installation & configuration
|
||||
**Estimation** : 3h
|
||||
**Labels** : `documentation`, `critique`, `on-premise`
|
||||
|
||||
@@ -1072,7 +1198,7 @@ Rédiger le guide complet d'installation et de configuration pour les collectivi
|
||||
|
||||
## 📚 JURIDIQUE & CDC
|
||||
|
||||
### Ticket #60 : [Doc] Amendement CDC v1.4 - Suppression SMS
|
||||
### Ticket #62 : [Doc] Amendement CDC v1.4 - Suppression SMS
|
||||
**Estimation** : 30min
|
||||
**Labels** : `documentation`, `cdc`
|
||||
|
||||
@@ -1089,7 +1215,7 @@ Amender le Cahier des Charges pour supprimer la mention des notifications SMS (p
|
||||
|
||||
---
|
||||
|
||||
### Ticket #61 : [Doc] Rédaction CGU/Privacy génériques v1
|
||||
### Ticket #63 : [Doc] Rédaction CGU/Privacy génériques v1
|
||||
**Estimation** : 8h
|
||||
**Labels** : `documentation`, `juridique`, `rgpd`
|
||||
|
||||
@@ -1109,36 +1235,42 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
||||
|
||||
## 📊 Résumé final
|
||||
|
||||
**Total** : 61 tickets
|
||||
**Estimation** : ~173h de développement
|
||||
**Total** : 65 tickets
|
||||
**Estimation** : ~184h de développement
|
||||
|
||||
### Par priorité
|
||||
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
||||
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
||||
- **P2 (Backend)** : 18 tickets (~50h)
|
||||
- **P3 (Frontend)** : 17 tickets (~52h) ← +1 ticket logs admin
|
||||
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
|
||||
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
||||
- **Critiques** : 6 tickets (~13h) ← -2 email, +1 logs, +1 CDC
|
||||
- **Critiques** : 6 tickets (~13h)
|
||||
- **Juridique** : 1 ticket (~8h)
|
||||
|
||||
### Par domaine
|
||||
- **BDD** : 7 tickets
|
||||
- **Backend** : 23 tickets ← +1 logs
|
||||
- **Frontend** : 17 tickets ← +1 logs admin
|
||||
- **Backend** : 23 tickets
|
||||
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
|
||||
- **Tests** : 3 tickets
|
||||
- **Documentation** : 5 tickets ← +1 amendement CDC
|
||||
- **Documentation** : 5 tickets
|
||||
- **Infra** : 2 tickets
|
||||
- **Juridique** : 1 ticket
|
||||
|
||||
### Modifications par rapport à la version initiale
|
||||
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
|
||||
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
||||
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
||||
- ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel
|
||||
- ✅ **Ajouté** : Ticket #56 "Écran Logs Admin" - Optionnel Phase 1.1
|
||||
- ✅ **Ajouté** : Ticket #60 "Amendement CDC v1.4 - Suppression SMS" - Simplification
|
||||
- ✅ **Ajouté** : Ticket #78 "Refonte Infrastructure Formulaires" - Harmonisation UI/UX
|
||||
- ✅ **Ajouté** : Ticket #79 "Renommer Nanny en AM" - Cohérence CDC
|
||||
- ✅ **Ajouté** : Ticket #81 "Corrections refactoring" - Bugfixes
|
||||
- ✅ **Ajouté** : Ticket #83 "RegisterChoiceScreen Mobile" - Responsive UX
|
||||
- ✅ **Fermé** : Ticket #82 "Écran Login mobile" - Merge develop + master
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 25 Novembre 2025
|
||||
**Version** : 1.0
|
||||
**Statut** : ✅ Prêt pour création dans Gitea
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.4
|
||||
**Statut** : ✅ Aligné avec le dépôt Gitea
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 📋 Décisions Projet - P'titsPas
|
||||
|
||||
**Version** : 1.0
|
||||
**Date** : 25 Novembre 2025
|
||||
**Version** : 1.1
|
||||
**Date** : 9 Février 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
|
||||
---
|
||||
@@ -49,7 +49,7 @@ ptitspas-app/
|
||||
|
||||
**Solution technique** :
|
||||
- Table `configuration` en BDD (clé/valeur)
|
||||
- Setup Wizard à la première connexion
|
||||
- Panneau Paramètres (3 sections) dans le dashboard à la première connexion ; navigation bloquée jusqu'à sauvegarde
|
||||
- Configuration SMTP dynamique
|
||||
- Personnalisation (nom app, logo, URL)
|
||||
|
||||
@@ -559,10 +559,11 @@ docs/
|
||||
| Date | Version | Modifications |
|
||||
|------|---------|---------------|
|
||||
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
||||
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 25 Novembre 2025
|
||||
**Version** : 1.0
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.1
|
||||
**Statut** : ✅ Document validé
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Procédure – Utilisation de l'API Gitea
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
- **Instance** : https://git.ptits-pas.fr
|
||||
- **API de base** : `https://git.ptits-pas.fr/api/v1`
|
||||
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Token
|
||||
|
||||
Le token est défini dans l'environnement (ex. `~/.bashrc`) :
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="<votre_token>"
|
||||
```
|
||||
|
||||
Pour l'utiliser dans les commandes :
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # ou : . ~/.bashrc
|
||||
# Puis utiliser $GITEA_TOKEN dans les curl
|
||||
```
|
||||
|
||||
### 2.2 En-tête HTTP
|
||||
|
||||
Toutes les requêtes API doivent envoyer le token :
|
||||
|
||||
```bash
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
|
||||
```
|
||||
|
||||
## 3. Endpoints utiles
|
||||
|
||||
### 3.1 Dépôt (repository)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
|
||||
| Liste dépôts | GET | `/repos/search?q=petitspas` |
|
||||
|
||||
Exemple – infos du dépôt :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
|
||||
```
|
||||
|
||||
### 3.2 Issues (tickets)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|------------------|---------|-----|
|
||||
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
|
||||
| Détail d'une issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
|
||||
|
||||
**Paramètres GET utiles pour la liste :**
|
||||
|
||||
- `state` : `open` ou `closed`
|
||||
- `labels` : filtre par label (ex. `frontend`)
|
||||
- `page`, `limit` : pagination
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Toutes les issues ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
|
||||
|
||||
# Issues ouvertes avec label "frontend"
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
|
||||
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
|
||||
|
||||
# Détail de l'issue #47
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
|
||||
|
||||
# Fermer l'issue #31
|
||||
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
|
||||
|
||||
# Créer une issue
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||
```
|
||||
|
||||
### 3.3 Pull requests
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
|
||||
| Détail d'une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
|
||||
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Liste des PR ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||
|
||||
# Créer une PR (head = branche source, base = branche cible)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||
```
|
||||
|
||||
### 3.4 Branches
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
|
||||
```
|
||||
|
||||
### 3.5 Webhooks
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
|
||||
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
|
||||
|
||||
### 3.6 Labels
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
|
||||
```
|
||||
|
||||
## 4. Résumé des URLs pour P'titsPas
|
||||
|
||||
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
|
||||
|
||||
| Ressource | URL |
|
||||
|------------------|-----|
|
||||
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
|
||||
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
|
||||
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
|
||||
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
|
||||
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
|
||||
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
|
||||
|
||||
## 5. Documentation officielle
|
||||
|
||||
- Swagger / OpenAPI : https://docs.gitea.com/api
|
||||
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
- **401 Unauthorized** : vérifier le token et l'en-tête `Authorization: token <TOKEN>`.
|
||||
- **404** : vérifier owner/repo et l'URL (sensible à la casse).
|
||||
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Note Backend - Activation du module Gestionnaires (Ticket #92)
|
||||
|
||||
## Problème
|
||||
L'endpoint `GET /api/v1/gestionnaires` renvoie une erreur **404 Not Found**.
|
||||
Cela est dû au fait que le `GestionnairesModule` n'est pas importé dans l'arbre des modules de l'application (via `UserModule` ou `AppModule`).
|
||||
|
||||
## Solution de contournement actuelle (Frontend)
|
||||
Le frontend utilise actuellement l'endpoint générique `/api/v1/users` et filtre les résultats côté client pour ne garder que les utilisateurs ayant le rôle `gestionnaire`.
|
||||
*Fichier concerné : `frontend/lib/services/user_service.dart`*
|
||||
|
||||
## Correctif Backend à appliquer
|
||||
Pour activer proprement l'endpoint dédié, il faut effectuer les modifications suivantes dans le backend :
|
||||
|
||||
### 1. Importer le module dans `UserModule`
|
||||
Fichier : `backend/src/routes/user/user.module.ts`
|
||||
|
||||
Ajouter `GestionnairesModule` dans les imports.
|
||||
|
||||
```typescript
|
||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... autres imports
|
||||
GestionnairesModule, // <--- AJOUTER ICI
|
||||
],
|
||||
// ...
|
||||
})
|
||||
export class UserModule { }
|
||||
```
|
||||
|
||||
### 2. Ajouter AuthModule dans `GestionnairesModule`
|
||||
Fichier : `backend/src/routes/user/gestionnaires/gestionnaires.module.ts`
|
||||
|
||||
Le contrôleur utilise `AuthGuard`, qui dépend de `JwtService` fourni par `AuthModule`.
|
||||
|
||||
```typescript
|
||||
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users]),
|
||||
AuthModule // <--- AJOUTER ICI
|
||||
],
|
||||
controllers: [GestionnairesController],
|
||||
providers: [GestionnairesService],
|
||||
})
|
||||
export class GestionnairesModule { }
|
||||
```
|
||||
|
||||
## Après application du correctif
|
||||
Une fois ces modifications backend effectuées :
|
||||
1. Redémarrer le serveur backend.
|
||||
2. Modifier le frontend (`frontend/lib/services/user_service.dart`) pour utiliser à nouveau l'endpoint dédié :
|
||||
```dart
|
||||
static Future<List<AppUser>> getGestionnaires() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,592 @@
|
||||
# Architecture Technique - P'titsPas
|
||||
## Guide d'Infrastructure et de Déploiement
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
P'titsPas est une application de gestion de garde d'enfants pour les collectivités locales, basée sur une architecture **client-serveur moderne** :
|
||||
|
||||
- **Frontend** : Application web Flutter (Single Page Application)
|
||||
- **Backend** : API REST Node.js/Express avec TypeScript
|
||||
- **Base de données** : PostgreSQL avec ORM Prisma
|
||||
- **Architecture** : Séparation claire frontend/backend avec API REST
|
||||
|
||||
---
|
||||
|
||||
## Prérequis Serveur
|
||||
|
||||
### Environnement d'exécution
|
||||
|
||||
#### Backend
|
||||
- **Node.js** : Version 18+ (LTS recommandée : 18.19.0+)
|
||||
- **npm** : Version 9+
|
||||
- **TypeScript** : Inclus dans les dépendances du projet
|
||||
|
||||
#### Base de données
|
||||
- **PostgreSQL** : Version 15+
|
||||
- **Extensions** : UUID (pour les clés primaires)
|
||||
|
||||
#### Frontend
|
||||
- **Serveur web statique** : nginx, Apache, ou similaire
|
||||
- **Flutter Web** : Compilation en JavaScript (pas de prérequis runtime)
|
||||
|
||||
### Ressources recommandées
|
||||
|
||||
#### Environnement de développement
|
||||
- **RAM** : 4GB minimum
|
||||
- **CPU** : 2 vCPU
|
||||
- **Storage** : 10GB
|
||||
|
||||
#### Environnement de production
|
||||
- **RAM** : 8GB recommandé (4GB minimum)
|
||||
- **CPU** : 4 vCPU recommandé (2 vCPU minimum)
|
||||
- **Storage** : 50GB minimum (base de données + logs + backups)
|
||||
- **Réseau** :
|
||||
- Port 3000 : API Backend (interne)
|
||||
- Port 80/443 : Web (externe)
|
||||
- Port 5432 : PostgreSQL (interne uniquement)
|
||||
|
||||
---
|
||||
|
||||
## Stack Technique Détaillée
|
||||
|
||||
### Backend (API)
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "Node.js 18+",
|
||||
"language": "TypeScript",
|
||||
"framework": "Express.js 4.18+",
|
||||
"orm": "Prisma 6.7+",
|
||||
"database_client": "@prisma/client",
|
||||
"security": [
|
||||
"helmet (sécurité headers)",
|
||||
"cors (CORS policy)",
|
||||
"bcrypt (hashage mots de passe)",
|
||||
"jsonwebtoken (JWT auth)"
|
||||
],
|
||||
"logging": "morgan",
|
||||
"validation": "@nestjs/common"
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend (Web)
|
||||
|
||||
```json
|
||||
{
|
||||
"framework": "Flutter 3.2.6+",
|
||||
"language": "Dart 3.0+",
|
||||
"compilation": "JavaScript (Flutter Web)",
|
||||
"navigation": "go_router 13.2+",
|
||||
"state_management": "provider 6.1+",
|
||||
"ui_framework": "Material Design",
|
||||
"fonts": "Google Fonts",
|
||||
"http_client": "http 1.2+"
|
||||
}
|
||||
```
|
||||
|
||||
### Base de données
|
||||
|
||||
```sql
|
||||
-- Structure PostgreSQL
|
||||
-- Tables principales :
|
||||
-- - Parent (utilisateurs parents)
|
||||
-- - Child (enfants)
|
||||
-- - Contract (contrats de garde)
|
||||
-- - Admin (administrateurs)
|
||||
-- - Theme (thèmes interface)
|
||||
-- - AppSettings (paramètres app)
|
||||
|
||||
-- Types de données :
|
||||
-- - UUID pour toutes les clés primaires
|
||||
-- - Timestamps automatiques (createdAt, updatedAt)
|
||||
-- - Enums pour les statuts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation et Configuration
|
||||
|
||||
### 1. Prérequis système
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install -y nodejs npm postgresql postgresql-contrib nginx
|
||||
|
||||
# Vérification versions
|
||||
node --version # >= 18.0.0
|
||||
npm --version # >= 9.0.0
|
||||
psql --version # >= 15.0
|
||||
```
|
||||
|
||||
### 2. Configuration base de données
|
||||
|
||||
```sql
|
||||
-- Se connecter en tant que postgres
|
||||
sudo -u postgres psql
|
||||
|
||||
-- Créer la base de données et l'utilisateur
|
||||
CREATE DATABASE ptitspas;
|
||||
CREATE USER ptitspas_user WITH PASSWORD 'secure_password_here';
|
||||
GRANT ALL PRIVILEGES ON DATABASE ptitspas TO ptitspas_user;
|
||||
ALTER USER ptitspas_user CREATEDB; -- Pour les migrations
|
||||
|
||||
-- Quitter
|
||||
\q
|
||||
```
|
||||
|
||||
### 3. Installation Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Installation des dépendances
|
||||
npm install
|
||||
|
||||
# Configuration environnement
|
||||
cp .env.example .env
|
||||
# Éditer .env avec vos paramètres
|
||||
|
||||
# Génération du client Prisma et migrations
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Initialisation admin (optionnel)
|
||||
npm run init-admin
|
||||
```
|
||||
|
||||
### 4. Installation Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Installation des dépendances Flutter
|
||||
flutter pub get
|
||||
|
||||
# Build pour production
|
||||
flutter build web --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'Environnement
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```bash
|
||||
# Base de données
|
||||
DATABASE_URL="postgresql://ptitspas_user:secure_password_here@localhost:5432/ptitspas"
|
||||
|
||||
# Sécurité
|
||||
JWT_SECRET="your-super-secret-jwt-key-minimum-32-characters"
|
||||
JWT_EXPIRES_IN="24h"
|
||||
|
||||
# Serveur
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Optionnel
|
||||
CORS_ORIGIN="https://ptitspas.yourdomain.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Déploiement
|
||||
|
||||
### Option 1 : Déploiement classique (recommandé)
|
||||
|
||||
#### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Installation production
|
||||
npm ci --only=production
|
||||
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Démarrage (avec PM2 recommandé)
|
||||
npm install -g pm2
|
||||
pm2 start dist/index.js --name "ptitspas-api"
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Build production
|
||||
flutter build web --release
|
||||
|
||||
# Copier vers serveur web
|
||||
sudo cp -r build/web/* /var/www/ptitspas/
|
||||
sudo chown -R www-data:www-data /var/www/ptitspas/
|
||||
```
|
||||
|
||||
### Option 2 : Conteneurisation Docker
|
||||
|
||||
#### Dockerfile Backend
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
# Créer répertoire app
|
||||
WORKDIR /app
|
||||
|
||||
# Copier package files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Installer dépendances
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copier code source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Générer client Prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Exposer port
|
||||
EXPOSE 3000
|
||||
|
||||
# Variables d'environnement
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Commande démarrage
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
#### Dockerfile Frontend
|
||||
|
||||
```dockerfile
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copier build Flutter
|
||||
COPY build/web /usr/share/nginx/html
|
||||
|
||||
# Configuration nginx
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Exposer port
|
||||
EXPOSE 80
|
||||
|
||||
# Démarrage nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ptitspas
|
||||
POSTGRES_USER: ptitspas_user
|
||||
POSTGRES_PASSWORD: secure_password_here
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://ptitspas_user:secure_password_here@postgres:5432/ptitspas
|
||||
JWT_SECRET: your-super-secret-jwt-key
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Nginx
|
||||
|
||||
### Configuration complète
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name ptitspas.yourdomain.com;
|
||||
|
||||
# Redirection HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name ptitspas.yourdomain.com;
|
||||
|
||||
# Certificats SSL
|
||||
ssl_certificate /etc/letsencrypt/live/ptitspas.yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ptitspas.yourdomain.com/privkey.pem;
|
||||
|
||||
# Configuration SSL sécurisée
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Frontend statique (Flutter Web)
|
||||
location / {
|
||||
root /var/www/ptitspas;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache statique
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# API Backend
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/ptitspas_access.log;
|
||||
error_log /var/log/nginx/ptitspas_error.log;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Obligatoire
|
||||
|
||||
1. **HTTPS avec certificat SSL**
|
||||
```bash
|
||||
# Installation Certbot
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
|
||||
# Génération certificat
|
||||
sudo certbot --nginx -d ptitspas.yourdomain.com
|
||||
|
||||
# Renouvellement automatique
|
||||
sudo crontab -e
|
||||
# Ajouter : 0 12 * * * /usr/bin/certbot renew --quiet
|
||||
```
|
||||
|
||||
2. **Firewall**
|
||||
```bash
|
||||
# UFW (Ubuntu)
|
||||
sudo ufw allow 22 # SSH
|
||||
sudo ufw allow 80 # HTTP
|
||||
sudo ufw allow 443 # HTTPS
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
3. **Base de données sécurisée**
|
||||
```bash
|
||||
# PostgreSQL : accès local uniquement
|
||||
sudo nano /etc/postgresql/15/main/postgresql.conf
|
||||
# Commenter : #listen_addresses = 'localhost'
|
||||
|
||||
sudo nano /etc/postgresql/15/main/pg_hba.conf
|
||||
# Vérifier que seules les connexions locales sont autorisées
|
||||
```
|
||||
|
||||
4. **Backup automatique**
|
||||
```bash
|
||||
# Script backup
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/var/backups/ptitspas"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
pg_dump -U ptitspas_user -h localhost ptitspas > $BACKUP_DIR/ptitspas_$DATE.sql
|
||||
|
||||
# Nettoyer les backups > 30 jours
|
||||
find $BACKUP_DIR -name "*.sql" -mtime +30 -delete
|
||||
|
||||
# Crontab : tous les jours à 2h
|
||||
# 0 2 * * * /path/to/backup_script.sh
|
||||
```
|
||||
|
||||
### Recommandé
|
||||
|
||||
1. **Fail2Ban** (protection brute force)
|
||||
```bash
|
||||
sudo apt install fail2ban
|
||||
sudo systemctl enable fail2ban
|
||||
```
|
||||
|
||||
2. **Monitoring des logs**
|
||||
```bash
|
||||
# Logrotate pour éviter les gros fichiers
|
||||
sudo nano /etc/logrotate.d/ptitspas
|
||||
```
|
||||
|
||||
3. **Updates automatiques**
|
||||
```bash
|
||||
sudo apt install unattended-upgrades
|
||||
sudo dpkg-reconfigure unattended-upgrades
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes de Gestion
|
||||
|
||||
### Démarrage des services
|
||||
|
||||
```bash
|
||||
# Backend (développement)
|
||||
cd backend && npm run dev
|
||||
|
||||
# Backend (production avec PM2)
|
||||
pm2 start ptitspas-api
|
||||
pm2 status
|
||||
|
||||
# Base de données
|
||||
sudo systemctl start postgresql
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Serveur web
|
||||
sudo systemctl start nginx
|
||||
sudo systemctl status nginx
|
||||
```
|
||||
|
||||
### Maintenance
|
||||
|
||||
```bash
|
||||
# Migrations base de données
|
||||
cd backend
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Logs Backend
|
||||
pm2 logs ptitspas-api
|
||||
|
||||
# Logs Nginx
|
||||
sudo tail -f /var/log/nginx/ptitspas_access.log
|
||||
sudo tail -f /var/log/nginx/ptitspas_error.log
|
||||
|
||||
# Restart services
|
||||
pm2 restart ptitspas-api
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring et Healthcheck
|
||||
|
||||
### Endpoints de santé
|
||||
|
||||
```bash
|
||||
# API Health (à implémenter)
|
||||
curl https://ptitspas.yourdomain.com/api/health
|
||||
|
||||
# Base de données
|
||||
psql -h localhost -U ptitspas_user -d ptitspas -c "SELECT 1;"
|
||||
|
||||
# Frontend
|
||||
curl -I https://ptitspas.yourdomain.com/
|
||||
```
|
||||
|
||||
### Logs à surveiller
|
||||
|
||||
1. **Backend** : Via PM2 ou logs applicatifs
|
||||
2. **PostgreSQL** : `/var/log/postgresql/postgresql-15-main.log`
|
||||
3. **Nginx** : `/var/log/nginx/ptitspas_*.log`
|
||||
4. **Système** : `/var/log/syslog`
|
||||
|
||||
### Métriques importantes
|
||||
|
||||
- **CPU/RAM** : Usage serveur
|
||||
- **Espace disque** : Base de données et logs
|
||||
- **Connexions DB** : Nombre de connexions actives
|
||||
- **Temps de réponse** : API et frontend
|
||||
- **Erreurs 5xx** : Erreurs serveur
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
1. **Backend ne démarre pas**
|
||||
```bash
|
||||
# Vérifier variables d'environnement
|
||||
cd backend && cat .env
|
||||
|
||||
# Vérifier connexion DB
|
||||
npx prisma db pull
|
||||
|
||||
# Logs détaillés
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **Frontend ne s'affiche pas**
|
||||
```bash
|
||||
# Vérifier build
|
||||
cd frontend && flutter build web
|
||||
|
||||
# Vérifier nginx
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
3. **Erreurs base de données**
|
||||
```bash
|
||||
# Vérifier statut PostgreSQL
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Vérifier connexions
|
||||
sudo -u postgres psql -c "SELECT * FROM pg_stat_activity;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Évolutivité
|
||||
|
||||
### Optimisations possibles
|
||||
|
||||
1. **Cache Redis** : Pour les sessions et cache applicatif
|
||||
2. **CDN** : Pour les assets statiques
|
||||
3. **Load Balancer** : Pour haute disponibilité
|
||||
4. **Clustering** : Multiple instances Node.js
|
||||
5. **Database replication** : Master/Slave PostgreSQL
|
||||
|
||||
### Monitoring avancé
|
||||
|
||||
- **Prometheus + Grafana** : Métriques système et applicatif
|
||||
- **ELK Stack** : Centralisation des logs
|
||||
- **Uptime monitoring** : Surveillance externe
|
||||
|
||||
Cette architecture est conçue pour être **scalable**, **maintenable** et **sécurisée** pour un environnement de production professionnel.
|
||||
@@ -61,7 +61,8 @@ changement_mdp_obligatoire: true (première connexion)
|
||||
|
||||
| # | Ticket | Description | Effort |
|
||||
|---|--------|-------------|--------|
|
||||
| **#14** | Setup Wizard | Écran configuration initiale (SMTP, app) | 4-5h |
|
||||
| **#12** | Panneau Paramètres / Configuration | Une page avec 3 sections (Email, Personnalisation, Avancé) ; première config = affichage direct + navigation bloquée jusqu'à sauvegarde | 5h |
|
||||
| **#13** | Intégration panneau au dashboard | Onglet Configuration, accès permanent, même interface | 1h |
|
||||
| **#47** | Changement MDP Obligatoire | Modale bloquante après login si flag=true | 1-2h |
|
||||
| **#35** | Création Gestionnaire | Formulaire création gestionnaire | 2-3h |
|
||||
|
||||
@@ -256,11 +257,11 @@ Configuration des appels API avec intercepteurs.
|
||||
## Recommandation de démarrage
|
||||
|
||||
1. **Ticket #47** - Modale changement MDP obligatoire (simple, rapide)
|
||||
2. **Ticket #14** - Setup Wizard (écran configuration initiale)
|
||||
2. **Tickets #12 + #13** - Panneau Paramètres (3 sections) : première config + onglet Configuration dans le dashboard
|
||||
3. **Ticket #35** - Formulaire création gestionnaire
|
||||
|
||||
Ces 3 tickets complètent le **workflow d'initialisation** !
|
||||
Ces tickets complètent le **workflow d'initialisation** !
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 27 janvier 2026*
|
||||
*Dernière mise à jour : 9 février 2026*
|
||||
|
||||
@@ -209,6 +209,7 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
||||
- [x] Ajouter d'autres évolutions identifiées
|
||||
- [ ] Mettre à jour le CDC original
|
||||
- [ ] Valider les modifications avec les parties prenantes
|
||||
- [ ] Modifier le texte de la checkbox de consentement photo (libellé actuel : 'J\'accepte l\'utilisation de ma photo.') sur l'écran d'inscription Nounou Étape 2 (`nanny_register_step2_screen.dart`).
|
||||
|
||||
# Évolutions proposées au cahier des charges
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
Point tickets frontend (API Gitea) - 27/01/2026
|
||||
================================================
|
||||
|
||||
Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8)
|
||||
|
||||
Num | Etat | Titre
|
||||
----+--------+--------------------------------------------------------
|
||||
35 | open | [Frontend] Écran Création Gestionnaire
|
||||
36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1)
|
||||
37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2)
|
||||
38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants)
|
||||
39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio
|
||||
40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité)
|
||||
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
||||
42 | closed | [Frontend] Inscription AM - Finalisation
|
||||
43 | open | [Frontend] Écran Création Mot de Passe
|
||||
44 | open | [Frontend] Dashboard Gestionnaire - Structure
|
||||
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
||||
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
||||
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
||||
48 | open | [Frontend] Gestion Erreurs & Messages
|
||||
49 | open | [Frontend] Écran Gestion Documents Légaux (Admin)
|
||||
50 | open | [Frontend] Affichage dynamique CGU lors inscription
|
||||
51 | open | [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||
54 | open | [Tests] Tests E2E Frontend
|
||||
82 | closed | [Frontend] Adapter �cran Login pour mobile
|
||||
83 | closed | [Frontend] Adapter �cran Choix Inscription pour mobile
|
||||
|
||||
Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83):
|
||||
#73 closed labels=[]
|
||||
#78 closed labels=[]
|
||||
#79 closed labels=[]
|
||||
#81 closed labels=[]
|
||||
#82 closed (écran Login mobile)
|
||||
#83 closed labels=['frontend', 'p3', 'phase-1', 'ux']
|
||||
@@ -0,0 +1,176 @@
|
||||
# Procédure – Utilisation de l’API Gitea
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
- **Instance** : https://git.ptits-pas.fr
|
||||
- **API de base** : `https://git.ptits-pas.fr/api/v1`
|
||||
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Token
|
||||
|
||||
Le token est défini dans l’environnement (ex. `~/.bashrc`) :
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="<votre_token>"
|
||||
```
|
||||
|
||||
Pour l’utiliser dans les commandes :
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # ou : . ~/.bashrc
|
||||
# Puis utiliser $GITEA_TOKEN dans les curl
|
||||
```
|
||||
|
||||
### 2.2 En-tête HTTP
|
||||
|
||||
Toutes les requêtes API doivent envoyer le token :
|
||||
|
||||
```bash
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
|
||||
```
|
||||
|
||||
## 3. Endpoints utiles
|
||||
|
||||
### 3.1 Dépôt (repository)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
|
||||
| Liste dépôts | GET | `/repos/search?q=petitspas` |
|
||||
|
||||
Exemple – infos du dépôt :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
|
||||
```
|
||||
|
||||
### 3.2 Issues (tickets)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|------------------|---------|-----|
|
||||
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
|
||||
| Détail d’une issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
|
||||
|
||||
**Paramètres GET utiles pour la liste :**
|
||||
|
||||
- `state` : `open` ou `closed`
|
||||
- `labels` : filtre par label (ex. `frontend`)
|
||||
- `page`, `limit` : pagination
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Toutes les issues ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
|
||||
|
||||
# Issues ouvertes avec label "frontend"
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
|
||||
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
|
||||
|
||||
# Détail de l’issue #47
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
|
||||
|
||||
# Fermer l’issue #31
|
||||
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
|
||||
|
||||
# Créer une issue
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||
```
|
||||
|
||||
### 3.3 Pull requests
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
|
||||
| Détail d’une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
|
||||
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Liste des PR ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||
|
||||
# Créer une PR (head = branche source, base = branche cible)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||
```
|
||||
|
||||
### 3.4 Branches
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
|
||||
```
|
||||
|
||||
### 3.5 Webhooks
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
|
||||
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
|
||||
|
||||
### 3.6 Labels
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
|
||||
```
|
||||
|
||||
## 4. Résumé des URLs pour P'titsPas
|
||||
|
||||
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
|
||||
|
||||
| Ressource | URL |
|
||||
|------------------|-----|
|
||||
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
|
||||
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
|
||||
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
|
||||
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
|
||||
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
|
||||
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
|
||||
|
||||
## 5. Documentation officielle
|
||||
|
||||
- Swagger / OpenAPI : https://docs.gitea.com/api
|
||||
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
- **401 Unauthorized** : vérifier le token et l’en-tête `Authorization: token <TOKEN>`.
|
||||
- **404** : vérifier owner/repo et l’URL (sensible à la casse).
|
||||
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Statut de l'application P'titsPas
|
||||
|
||||
**Date du point** : 8 février 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Environnement de production
|
||||
|
||||
| Élément | Statut | Détail |
|
||||
|--------|--------|--------|
|
||||
| **URL** | OK | https://app.ptits-pas.fr |
|
||||
| **Frontend** | 200 | Flutter Web, Nginx |
|
||||
| **API** | 200 | NestJS, préfixe `/api/v1` |
|
||||
| **Base de données** | OK | PostgreSQL 17 |
|
||||
| **PgAdmin** | OK | https://app.ptits-pas.fr/pgadmin |
|
||||
|
||||
### Conteneurs Docker
|
||||
|
||||
| Service | Image | État |
|
||||
|---------|--------|------|
|
||||
| ptitspas-frontend | ptitspas-app-frontend | Up (recréé récemment) |
|
||||
| ptitspas-backend | ptitspas-app-backend | Up ~26h |
|
||||
| ptitspas-postgres | postgres:17 | Up ~28h |
|
||||
| ptitspas-pgadmin | dpage/pgadmin4 | Up ~28h |
|
||||
|
||||
---
|
||||
|
||||
## 2. Dépôt Git
|
||||
|
||||
- **Branche déployée** : `master`
|
||||
- **Derniers commits** :
|
||||
- `10bf255` – fix(ui): renforcer ombre boutons Parents/AM sur mobile
|
||||
- `678f421` – docs: ticket #82 fermé (écran Login mobile)
|
||||
- `5295e8e` – Merge develop: login mobile, formulaire sous slogan par ratio
|
||||
- `6bf0932` – docs: Index, doc API Gitea, script fermeture issue
|
||||
- `2f1740b` – docs: ticket #83 RegisterChoiceScreen Mobile (terminé)
|
||||
|
||||
- **Branches actives** : `master`, `develop`, diverses `feature/*` (inscription, config, documents légaux, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Déploiement (hook Gitea)
|
||||
|
||||
| Élément | Statut |
|
||||
|--------|--------|
|
||||
| **Webhook** | Opérationnel (`hooks.ptits-pas.fr/hooks/petitspas-deploy`) |
|
||||
| **Déclencheur** | Push sur `master`, dépôt `petitspas` |
|
||||
| **Script** | Monté depuis l’hôte (verrou + sans Prisma) |
|
||||
| **Dernier déploiement** | 08/02/2026 18:18:26 – Succès |
|
||||
|
||||
Un seul déploiement à la fois (verrou) ; plus d’étape Prisma dans le script.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fonctionnalités livrées
|
||||
|
||||
### Backend (API)
|
||||
|
||||
- Auth : login, refresh, profil, **changement MDP obligatoire** (first login)
|
||||
- Configuration : setup status, bulk, test SMTP, catégories
|
||||
- Documents légaux : actifs, versions, upload, activation, téléchargement
|
||||
- Inscription : parents (workflow complet), enfants (CRUD)
|
||||
- Compte super_admin par défaut (seed BDD) : `admin@ptits-pas.fr` / `4dm1n1strateur`
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Formulaires d’inscription** : compatibles **desktop et mobile**
|
||||
- Choix d’inscription (Parents / Assistante maternelle) – responsive
|
||||
- Inscription Parent : étapes 1 à 5 (infos parent 1 & 2, enfants, présentation, CGU, récap)
|
||||
- Inscription AM : étapes 1 à 4 (identité, pro, présentation, récap)
|
||||
- **Login** : écran adapté mobile (formulaire sous slogan selon ratio)
|
||||
- Modale **changement de mot de passe obligatoire** après première connexion si `changement_mdp_obligatoire`
|
||||
- CORS configuré (localhost + prod)
|
||||
|
||||
### Base de données
|
||||
|
||||
- Schéma database-first (BDD.sql)
|
||||
- Tables : utilisateurs, configuration, documents_legaux, acceptations_documents, enfants, etc.
|
||||
- Champs tokens création MDP, genre enfants, configuration système
|
||||
|
||||
---
|
||||
|
||||
## 5. Tickets / Priorités (résumé)
|
||||
|
||||
- **Liste détaillée** : `docs/23_LISTE-TICKETS.md`
|
||||
- **Récent fermé** : #82 (Login mobile), #83 (RegisterChoiceScreen mobile), #73, #78, #79, #81
|
||||
- **P0 (BDD)** : quelques amendements ouverts (champs CDC, présentation dossier, etc.)
|
||||
- **P1** : configuration système (panneau Paramètres, 3 sections, première config + accès permanent)
|
||||
- **P2/P3** : backend métier et frontend (dashboards, écrans création MDP, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Documentation utile
|
||||
|
||||
| Fichier | Usage |
|
||||
|---------|--------|
|
||||
| `00_INDEX.md` | Index de la doc |
|
||||
| `01_CAHIER-DES-CHARGES.md` | CDC v1.3 |
|
||||
| `11_API.md` | Endpoints API |
|
||||
| `20_WORKFLOW-CREATION-COMPTE.md` | Workflow création compte |
|
||||
| `23_LISTE-TICKETS.md` | Liste des tickets |
|
||||
| `BRIEFING-FRONTEND.md` | Brief frontend, accès Git, tickets prioritaires |
|
||||
| `PROCEDURE-API-GITEA.md` | Utilisation API Gitea (issues, PR, token) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Synthèse
|
||||
|
||||
L’application est **en production** sur https://app.ptits-pas.fr avec :
|
||||
|
||||
- Frontend et API accessibles et répondant en 200.
|
||||
- Déploiement automatique sur push `master` avec script à jour (verrou, sans Prisma).
|
||||
- Formulaires d’inscription (Parents et AM) **responsive desktop et mobile**.
|
||||
- Login et changement de mot de passe obligatoire opérationnels.
|
||||
- Prochaines priorités : P0 BDD si besoin, P1 panneau Paramètres / Configuration (tickets #12, #13), puis dashboards et workflows métier (P2/P3).
|
||||
@@ -1,44 +0,0 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
flutter.sdk=/home/deploy/snap/flutter/common/flutter
|
||||
flutter.sdk=C:\\Users\\marti\\dev\\flutter
|
||||
sdk.dir=C:\\Users\\myhan\\AppData\\Local\\Android\\Sdk
|
||||
|
Before Width: | Height: | Size: 510 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect x="271" y="17" width="193" height="96" rx="19.2" fill="#f9bc8e" />
|
||||
<rect x="168" y="108" width="206" height="115" rx="23.0" fill="#f7db75" />
|
||||
<rect x="131" y="229" width="223" height="132" rx="26.4" fill="#aadac2" />
|
||||
<rect x="47" y="349" width="238" height="144" rx="28.8" fill="#dfc2cf" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Water‑color like noise -->
|
||||
<filter id="wcTexture" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" seed="12" result="noise"/>
|
||||
<feBlend in="SourceGraphic" in2="noise" mode="multiply"/>
|
||||
</filter>
|
||||
|
||||
<!-- Gradients -->
|
||||
<radialGradient id="gradCoral" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0%" stop-color="#ffddc9"/>
|
||||
<stop offset="100%" stop-color="#f49c6e"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="gradYellow" cx="45%" cy="35%" r="70%">
|
||||
<stop offset="0%" stop-color="#fff6c9"/>
|
||||
<stop offset="100%" stop-color="#e9c833"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="gradMint" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#d4f4ec"/>
|
||||
<stop offset="100%" stop-color="#6bbda3"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="gradLavender" cx="35%" cy="25%" r="70%">
|
||||
<stop offset="0%" stop-color="#f5e6ff"/>
|
||||
<stop offset="100%" stop-color="#b289c9"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- CORAL -->
|
||||
<path filter="url(#wcTexture)" fill="url(#gradCoral)" d="
|
||||
M 360 40
|
||||
Q 380 15 420 22
|
||||
L 450 28
|
||||
Q 480 35 490 60
|
||||
L 495 80
|
||||
Q 500 105 470 120
|
||||
L 440 135
|
||||
Q 410 150 370 135
|
||||
L 345 120
|
||||
Q 315 105 325 75
|
||||
L 330 55
|
||||
Q 335 50 360 40 Z"/>
|
||||
|
||||
<!-- YELLOW -->
|
||||
<path filter="url(#wcTexture)" fill="url(#gradYellow)" d="
|
||||
M 280 190
|
||||
Q 300 170 340 175
|
||||
L 370 180
|
||||
Q 405 185 410 210
|
||||
L 415 230
|
||||
Q 420 255 390 270
|
||||
L 355 285
|
||||
Q 320 300 290 285
|
||||
L 265 270
|
||||
Q 235 255 245 225
|
||||
L 250 205
|
||||
Q 255 200 280 190 Z"/>
|
||||
|
||||
<!-- MINT -->
|
||||
<path filter="url(#wcTexture)" fill="url(#gradMint)" d="
|
||||
M 180 330
|
||||
Q 205 310 255 315
|
||||
L 285 320
|
||||
Q 325 325 330 350
|
||||
L 335 370
|
||||
Q 340 395 305 410
|
||||
L 275 425
|
||||
Q 235 440 200 425
|
||||
L 175 410
|
||||
Q 145 395 155 365
|
||||
L 160 345
|
||||
Q 165 340 180 330 Z"/>
|
||||
|
||||
<!-- LAVENDER -->
|
||||
<path filter="url(#wcTexture)" fill="url(#gradLavender)" d="
|
||||
M 80 460
|
||||
Q 100 440 160 445
|
||||
L 190 450
|
||||
Q 235 455 240 480
|
||||
L 245 500
|
||||
Q 250 525 210 540
|
||||
L 170 555
|
||||
Q 130 570 95 555
|
||||
L 65 540
|
||||
Q 35 525 45 495
|
||||
L 50 475
|
||||
Q 55 470 80 460 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// Models
|
||||
import '../models/user_registration_data.dart';
|
||||
import '../models/am_registration_data.dart';
|
||||
|
||||
// Screens
|
||||
import '../screens/auth/login_screen.dart';
|
||||
import '../screens/auth/register_choice_screen.dart';
|
||||
import '../screens/auth/parent_register_step1_screen.dart';
|
||||
import '../screens/auth/parent_register_step2_screen.dart';
|
||||
import '../screens/auth/parent_register_step3_screen.dart';
|
||||
import '../screens/auth/parent_register_step4_screen.dart';
|
||||
import '../screens/auth/parent_register_step5_screen.dart';
|
||||
import '../screens/auth/am_register_step1_screen.dart';
|
||||
import '../screens/auth/am_register_step2_screen.dart';
|
||||
import '../screens/auth/am_register_step3_screen.dart';
|
||||
import '../screens/auth/am_register_step4_screen.dart';
|
||||
import '../screens/home/home_screen.dart';
|
||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
||||
import '../screens/unknown_screen.dart';
|
||||
|
||||
// --- Provider Instances ---
|
||||
// It's generally better to provide these higher up the widget tree if possible,
|
||||
// or ensure they are created only once.
|
||||
// For ShellRoute, creating them here and passing via .value is common.
|
||||
|
||||
final userRegistrationDataNotifier = UserRegistrationData();
|
||||
final amRegistrationDataNotifier = AmRegistrationData();
|
||||
|
||||
class AppRouter {
|
||||
static final GoRouter router = GoRouter(
|
||||
initialLocation: '/login',
|
||||
errorBuilder: (context, state) => const UnknownScreen(),
|
||||
debugLogDiagnostics: true,
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (BuildContext context, GoRouterState state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/register-choice',
|
||||
builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/admin-dashboard',
|
||||
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent-dashboard',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/am-dashboard',
|
||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||
),
|
||||
|
||||
// --- Parent Registration Flow ---
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
return ChangeNotifierProvider<UserRegistrationData>.value(
|
||||
value: userRegistrationDataNotifier,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/parent-register-step1',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentRegisterStep1Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent-register-step2',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentRegisterStep2Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent-register-step3',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentRegisterStep3Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent-register-step4',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentRegisterStep4Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent-register-step5',
|
||||
builder: (BuildContext context, GoRouterState state) => const ParentRegisterStep5Screen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- AM (Assistante Maternelle) Registration Flow ---
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
return ChangeNotifierProvider<AmRegistrationData>.value(
|
||||
value: amRegistrationDataNotifier,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/am-register-step1',
|
||||
builder: (BuildContext context, GoRouterState state) => const AmRegisterStep1Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/am-register-step2',
|
||||
builder: (BuildContext context, GoRouterState state) => const AmRegisterStep2Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/am-register-step3',
|
||||
builder: (BuildContext context, GoRouterState state) => const AmRegisterStep3Screen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/am-register-step4',
|
||||
builder: (BuildContext context, GoRouterState state) => const AmRegisterStep4Screen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Mode d'affichage d'un formulaire
|
||||
enum DisplayMode {
|
||||
/// Mode éditable (formulaire d'inscription)
|
||||
editable,
|
||||
|
||||
/// Mode lecture seule (récapitulatif)
|
||||
readonly,
|
||||
}
|
||||
|
||||
/// Configuration d'affichage pour les widgets de formulaire
|
||||
class DisplayConfig {
|
||||
/// Mode d'affichage (editable/readonly)
|
||||
final DisplayMode mode;
|
||||
|
||||
/// Type de layout détecté (mobile/desktop)
|
||||
final LayoutType layoutType;
|
||||
|
||||
const DisplayConfig({
|
||||
required this.mode,
|
||||
required this.layoutType,
|
||||
});
|
||||
|
||||
/// Crée une config à partir du contexte
|
||||
factory DisplayConfig.fromContext(
|
||||
BuildContext context, {
|
||||
DisplayMode mode = DisplayMode.editable,
|
||||
}) {
|
||||
return DisplayConfig(
|
||||
mode: mode,
|
||||
layoutType: LayoutHelper.getLayoutType(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Est en mode éditable
|
||||
bool get isEditable => mode == DisplayMode.editable;
|
||||
|
||||
/// Est en mode lecture seule
|
||||
bool get isReadonly => mode == DisplayMode.readonly;
|
||||
|
||||
/// Est en layout mobile
|
||||
bool get isMobile => layoutType == LayoutType.mobile;
|
||||
|
||||
/// Est en layout desktop
|
||||
bool get isDesktop => layoutType == LayoutType.desktop;
|
||||
|
||||
/// Layout vertical (mobile)
|
||||
bool get isVerticalLayout => isMobile;
|
||||
|
||||
/// Layout horizontal (desktop)
|
||||
bool get isHorizontalLayout => isDesktop;
|
||||
}
|
||||
|
||||
/// Type de layout
|
||||
enum LayoutType {
|
||||
/// Mobile (< 600px) - toujours vertical
|
||||
mobile,
|
||||
|
||||
/// Desktop/Tablette (≥ 600px) - horizontal
|
||||
desktop,
|
||||
}
|
||||
|
||||
/// Utilitaires pour la détection de layout
|
||||
class LayoutHelper {
|
||||
/// Seuil de largeur pour mobile/desktop (en pixels)
|
||||
static const double mobileBreakpoint = 600.0;
|
||||
|
||||
/// Détermine le type de layout selon la largeur d'écran
|
||||
static LayoutType getLayoutType(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return width < mobileBreakpoint
|
||||
? LayoutType.mobile
|
||||
: LayoutType.desktop;
|
||||
}
|
||||
|
||||
/// Vérifie si on est sur mobile
|
||||
static bool isMobile(BuildContext context) {
|
||||
return getLayoutType(context) == LayoutType.mobile;
|
||||
}
|
||||
|
||||
/// Vérifie si on est sur desktop
|
||||
static bool isDesktop(BuildContext context) {
|
||||
return getLayoutType(context) == LayoutType.desktop;
|
||||
}
|
||||
|
||||
/// Retourne un espacement adapté au layout
|
||||
static double getSpacing(BuildContext context, {
|
||||
double mobileSpacing = 12.0,
|
||||
double desktopSpacing = 20.0,
|
||||
}) {
|
||||
return isMobile(context) ? mobileSpacing : desktopSpacing;
|
||||
}
|
||||
|
||||
/// Retourne une largeur max adaptée au layout
|
||||
static double getMaxWidth(BuildContext context, {
|
||||
double? mobileMaxWidth,
|
||||
double? desktopMaxWidth,
|
||||
}) {
|
||||
if (isMobile(context)) {
|
||||
return mobileMaxWidth ?? double.infinity;
|
||||
} else {
|
||||
return desktopMaxWidth ?? 1200.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ class Env {
|
||||
);
|
||||
|
||||
// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/')
|
||||
static String apiV1(String path) => "${apiBaseUrl}/api/v1$path";
|
||||
static String apiV1(String path) => '$apiBaseUrl/api/v1$path';
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart'; // Import pour la localisation
|
||||
// import 'package:provider/provider.dart'; // Supprimer Provider
|
||||
import 'navigation/app_router.dart';
|
||||
import 'config/app_router.dart'; // <-- Importer le bon routeur (GoRouter)
|
||||
// import 'theme/app_theme.dart'; // Supprimer AppTheme
|
||||
// import 'theme/theme_provider.dart'; // Supprimer ThemeProvider
|
||||
|
||||
@@ -17,7 +17,7 @@ class MyApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
// Pas besoin de Provider.of ici
|
||||
|
||||
return MaterialApp(
|
||||
return MaterialApp.router( // <-- Utilisation de MaterialApp.router
|
||||
title: 'P\'titsPas',
|
||||
theme: ThemeData.light().copyWith( // Utiliser un thème simple par défaut
|
||||
textTheme: GoogleFonts.meriendaTextTheme(
|
||||
@@ -35,8 +35,7 @@ class MyApp extends StatelessWidget {
|
||||
// Locale('en', 'US'), // Anglais, si besoin
|
||||
],
|
||||
locale: const Locale('fr', 'FR'), // Forcer la locale française par défaut
|
||||
initialRoute: AppRouter.login,
|
||||
onGenerateRoute: AppRouter.generateRoute,
|
||||
routerConfig: AppRouter.router, // <-- Passer la configuration du GoRouter
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AmRegistrationData extends ChangeNotifier {
|
||||
// Step 1: Identity Info
|
||||
String firstName = '';
|
||||
String lastName = '';
|
||||
String streetAddress = ''; // Nouveau pour N° et Rue
|
||||
String postalCode = ''; // Nouveau
|
||||
String city = ''; // Nouveau
|
||||
String phone = '';
|
||||
String email = '';
|
||||
String password = '';
|
||||
// String? photoPath; // Déplacé ou géré à l'étape 2
|
||||
// bool photoConsent = false; // Déplacé ou géré à l'étape 2
|
||||
|
||||
// Step 2: Professional Info
|
||||
String? photoPath; // Ajouté pour l'étape 2
|
||||
bool photoConsent = false; // Ajouté pour l'étape 2
|
||||
DateTime? dateOfBirth;
|
||||
String birthCity = ''; // Nouveau
|
||||
String birthCountry = ''; // Nouveau
|
||||
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
|
||||
String nir = ''; // Numéro de Sécurité Sociale
|
||||
String agrementNumber = ''; // Numéro d'agrément
|
||||
int? capacity; // Number of children the AM can look after
|
||||
|
||||
// Step 3: Presentation & CGU
|
||||
String presentationText = '';
|
||||
bool cguAccepted = false;
|
||||
|
||||
// --- Methods to update data and notify listeners ---
|
||||
|
||||
void updateIdentityInfo({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? streetAddress, // Modifié
|
||||
String? postalCode, // Nouveau
|
||||
String? city, // Nouveau
|
||||
String? phone,
|
||||
String? email,
|
||||
String? password,
|
||||
}) {
|
||||
this.firstName = firstName ?? this.firstName;
|
||||
this.lastName = lastName ?? this.lastName;
|
||||
this.streetAddress = streetAddress ?? this.streetAddress; // Modifié
|
||||
this.postalCode = postalCode ?? this.postalCode; // Nouveau
|
||||
this.city = city ?? this.city; // Nouveau
|
||||
this.phone = phone ?? this.phone;
|
||||
this.email = email ?? this.email;
|
||||
this.password = password ?? this.password;
|
||||
// if (photoPath != null || this.photoPath != null) { // Supprimé de l'étape 1
|
||||
// this.photoPath = photoPath;
|
||||
// }
|
||||
// this.photoConsent = photoConsent ?? this.photoConsent; // Supprimé de l'étape 1
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateProfessionalInfo({
|
||||
String? photoPath,
|
||||
bool? photoConsent,
|
||||
DateTime? dateOfBirth,
|
||||
String? birthCity, // Nouveau
|
||||
String? birthCountry, // Nouveau
|
||||
// String? placeOfBirth, // Remplacé
|
||||
String? nir,
|
||||
String? agrementNumber,
|
||||
int? capacity,
|
||||
}) {
|
||||
// Allow setting photoPath to null explicitly
|
||||
if (photoPath != null || this.photoPath != null) {
|
||||
this.photoPath = photoPath;
|
||||
}
|
||||
this.photoConsent = photoConsent ?? this.photoConsent;
|
||||
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
|
||||
this.birthCity = birthCity ?? this.birthCity; // Nouveau
|
||||
this.birthCountry = birthCountry ?? this.birthCountry; // Nouveau
|
||||
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
|
||||
this.nir = nir ?? this.nir;
|
||||
this.agrementNumber = agrementNumber ?? this.agrementNumber;
|
||||
this.capacity = capacity ?? this.capacity;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePresentationAndCgu({
|
||||
String? presentationText,
|
||||
bool? cguAccepted,
|
||||
}) {
|
||||
this.presentationText = presentationText ?? this.presentationText;
|
||||
this.cguAccepted = cguAccepted ?? this.cguAccepted;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- Getters for validation or display ---
|
||||
bool get isStep1Complete =>
|
||||
firstName.isNotEmpty &&
|
||||
lastName.isNotEmpty &&
|
||||
streetAddress.isNotEmpty && // Modifié
|
||||
postalCode.isNotEmpty && // Nouveau
|
||||
city.isNotEmpty && // Nouveau
|
||||
phone.isNotEmpty &&
|
||||
email.isNotEmpty;
|
||||
// password n'est pas requis à l'inscription (défini après validation par lien email)
|
||||
|
||||
bool get isStep2Complete =>
|
||||
// photoConsent is mandatory if a photo is system-required, otherwise optional.
|
||||
// For now, let's assume if photoPath is present, consent should ideally be true.
|
||||
// Or, make consent always mandatory if photo section exists.
|
||||
// Based on new mockup, photo is present, so consent might be implicitly or explicitly needed.
|
||||
(photoPath != null ? photoConsent == true : true) && // Ajuster selon la logique de consentement désirée
|
||||
dateOfBirth != null &&
|
||||
birthCity.isNotEmpty &&
|
||||
birthCountry.isNotEmpty &&
|
||||
nir.isNotEmpty && // Basic check, could add validation
|
||||
agrementNumber.isNotEmpty &&
|
||||
capacity != null && capacity! > 0;
|
||||
|
||||
bool get isStep3Complete =>
|
||||
// presentationText is optional as per CDC (message au gestionnaire)
|
||||
cguAccepted;
|
||||
|
||||
bool get isRegistrationComplete =>
|
||||
isStep1Complete && isStep2Complete && isStep3Complete;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmRegistrationData('
|
||||
'firstName: $firstName, lastName: $lastName, '
|
||||
'streetAddress: $streetAddress, postalCode: $postalCode, city: $city, '
|
||||
'phone: $phone, email: $email, '
|
||||
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
|
||||
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
|
||||
'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, '
|
||||
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
|
||||
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
|
||||
class AssistanteMaternelleModel {
|
||||
final AppUser user;
|
||||
final String? approvalNumber;
|
||||
final String? residenceCity;
|
||||
final int? maxChildren;
|
||||
final int? placesAvailable;
|
||||
|
||||
AssistanteMaternelleModel({
|
||||
required this.user,
|
||||
this.approvalNumber,
|
||||
this.residenceCity,
|
||||
this.maxChildren,
|
||||
this.placesAvailable,
|
||||
});
|
||||
|
||||
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
||||
final userJson = json['user'] ?? json;
|
||||
final user = AppUser.fromJson(userJson);
|
||||
|
||||
return AssistanteMaternelleModel(
|
||||
user: user,
|
||||
approvalNumber: json['numero_agrement'] as String?,
|
||||
residenceCity: json['ville_residence'] as String?,
|
||||
maxChildren: json['nb_max_enfants'] as int?,
|
||||
placesAvailable: json['place_disponible'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
|
||||
class ParentModel {
|
||||
final AppUser user;
|
||||
final int childrenCount;
|
||||
|
||||
ParentModel({required this.user, this.childrenCount = 0});
|
||||
|
||||
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
||||
final userJson = json['user'] ?? json;
|
||||
final user = AppUser.fromJson(userJson);
|
||||
final children = json['parentChildren'] as List?;
|
||||
return ParentModel(
|
||||
user: user,
|
||||
childrenCount: children?.length ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@ class AppUser {
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final bool changementMdpObligatoire;
|
||||
final String? nom;
|
||||
final String? prenom;
|
||||
final String? statut;
|
||||
final String? telephone;
|
||||
final String? photoUrl;
|
||||
final String? adresse;
|
||||
final String? ville;
|
||||
final String? codePostal;
|
||||
|
||||
AppUser({
|
||||
required this.id,
|
||||
@@ -13,16 +21,53 @@ class AppUser {
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.changementMdpObligatoire = false,
|
||||
this.nom,
|
||||
this.prenom,
|
||||
this.statut,
|
||||
this.telephone,
|
||||
this.photoUrl,
|
||||
this.adresse,
|
||||
this.ville,
|
||||
this.codePostal,
|
||||
});
|
||||
|
||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||
final id = json['id']?.toString();
|
||||
final email = json['email']?.toString();
|
||||
final role = json['role']?.toString();
|
||||
if (id == null || id.isEmpty) {
|
||||
throw Exception('Profil invalide: id manquant');
|
||||
}
|
||||
if (email == null || email.isEmpty) {
|
||||
throw Exception('Profil invalide: email manquant');
|
||||
}
|
||||
if (role == null || role.isEmpty) {
|
||||
throw Exception('Profil invalide: rôle manquant');
|
||||
}
|
||||
return AppUser(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String,
|
||||
role: json['role'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false,
|
||||
id: id,
|
||||
email: email,
|
||||
role: role,
|
||||
createdAt: json['cree_le'] != null
|
||||
? DateTime.tryParse(json['cree_le'].toString()) ?? DateTime.now()
|
||||
: (json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now()
|
||||
: DateTime.now()),
|
||||
updatedAt: json['modifie_le'] != null
|
||||
? DateTime.tryParse(json['modifie_le'].toString()) ?? DateTime.now()
|
||||
: (json['updatedAt'] != null
|
||||
? DateTime.tryParse(json['updatedAt'].toString()) ?? DateTime.now()
|
||||
: DateTime.now()),
|
||||
changementMdpObligatoire:
|
||||
json['changement_mdp_obligatoire'] == true,
|
||||
nom: json['nom']?.toString(),
|
||||
prenom: json['prenom']?.toString(),
|
||||
statut: json['statut']?.toString(),
|
||||
telephone: json['telephone']?.toString(),
|
||||
photoUrl: json['photo_url']?.toString(),
|
||||
adresse: json['adresse']?.toString(),
|
||||
ville: json['ville']?.toString(),
|
||||
codePostal: json['code_postal']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +79,16 @@ class AppUser {
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'changement_mdp_obligatoire': changementMdpObligatoire,
|
||||
'nom': nom,
|
||||
'prenom': prenom,
|
||||
'statut': statut,
|
||||
'telephone': telephone,
|
||||
'photo_url': photoUrl,
|
||||
'adresse': adresse,
|
||||
'ville': ville,
|
||||
'code_postal': codePostal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io'; // Pour File
|
||||
import '../models/card_assets.dart'; // Import de l'enum CardColorVertical
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
// import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé
|
||||
|
||||
class ParentData {
|
||||
String firstName;
|
||||
@@ -47,12 +50,28 @@ class ChildData {
|
||||
});
|
||||
}
|
||||
|
||||
class UserRegistrationData {
|
||||
// Nouvelle classe pour les détails bancaires
|
||||
class BankDetails {
|
||||
String bankName;
|
||||
String iban;
|
||||
String bic;
|
||||
|
||||
BankDetails({
|
||||
this.bankName = '',
|
||||
this.iban = '',
|
||||
this.bic = '',
|
||||
});
|
||||
}
|
||||
|
||||
class UserRegistrationData extends ChangeNotifier {
|
||||
ParentData parent1;
|
||||
ParentData? parent2; // Optionnel
|
||||
List<ChildData> children;
|
||||
String motivationText;
|
||||
bool cguAccepted;
|
||||
BankDetails? bankDetails; // Ajouté
|
||||
String attestationCafNumber; // Ajouté
|
||||
bool consentQuotientFamilial; // Ajouté
|
||||
|
||||
UserRegistrationData({
|
||||
ParentData? parent1Data,
|
||||
@@ -60,38 +79,77 @@ class UserRegistrationData {
|
||||
List<ChildData>? childrenData,
|
||||
this.motivationText = '',
|
||||
this.cguAccepted = false,
|
||||
this.bankDetails, // Ajouté
|
||||
this.attestationCafNumber = '', // Ajouté
|
||||
this.consentQuotientFamilial = false, // Ajouté
|
||||
}) : parent1 = parent1Data ?? ParentData(),
|
||||
children = childrenData ?? [];
|
||||
|
||||
// Méthode pour ajouter/mettre à jour le parent 1
|
||||
void updateParent1(ParentData data) {
|
||||
parent1 = data;
|
||||
notifyListeners(); // Notifier les changements
|
||||
}
|
||||
|
||||
// Méthode pour ajouter/mettre à jour le parent 2
|
||||
void updateParent2(ParentData? data) {
|
||||
parent2 = data;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Méthode pour ajouter un enfant
|
||||
void addChild(ChildData child) {
|
||||
children.add(child);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Méthode pour mettre à jour un enfant (si nécessaire plus tard)
|
||||
void updateChild(int index, ChildData child) {
|
||||
if (index >= 0 && index < children.length) {
|
||||
children[index] = child;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour supprimer un enfant
|
||||
void removeChild(int index) {
|
||||
if (index >= 0 && index < children.length) {
|
||||
children.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour la motivation
|
||||
void updateMotivation(String text) {
|
||||
motivationText = text;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Mettre à jour les informations bancaires et CAF
|
||||
void updateFinancialInfo({
|
||||
BankDetails? bankDetails,
|
||||
String? attestationCafNumber,
|
||||
bool? consentQuotientFamilial,
|
||||
}) {
|
||||
if (bankDetails != null) this.bankDetails = bankDetails;
|
||||
if (attestationCafNumber != null) this.attestationCafNumber = attestationCafNumber;
|
||||
if (consentQuotientFamilial != null) this.consentQuotientFamilial = consentQuotientFamilial;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Accepter les CGU
|
||||
void acceptCGU() {
|
||||
cguAccepted = true;
|
||||
void acceptCGU(bool accepted) { // Prend un booléen
|
||||
cguAccepted = accepted;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si toutes les données requises sont là (simplifié)
|
||||
bool isRegistrationComplete() {
|
||||
// Ajouter ici les validations nécessaires
|
||||
// Exemple : parent1 doit avoir des champs remplis, au moins un enfant, CGU acceptées
|
||||
return parent1.firstName.isNotEmpty &&
|
||||
parent1.lastName.isNotEmpty &&
|
||||
children.isNotEmpty &&
|
||||
cguAccepted;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../screens/auth/login_screen.dart';
|
||||
import '../screens/auth/register_choice_screen.dart';
|
||||
import '../screens/auth/parent_register_step1_screen.dart';
|
||||
import '../screens/auth/parent_register_step2_screen.dart';
|
||||
import '../screens/auth/parent_register_step3_screen.dart';
|
||||
import '../screens/auth/parent_register_step4_screen.dart';
|
||||
import '../screens/auth/parent_register_step5_screen.dart';
|
||||
import '../screens/home/home_screen.dart';
|
||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
||||
import '../models/user_registration_data.dart';
|
||||
|
||||
class AppRouter {
|
||||
static const String login = '/login';
|
||||
static const String registerChoice = '/register-choice';
|
||||
static const String parentRegisterStep1 = '/parent-register/step1';
|
||||
static const String parentRegisterStep2 = '/parent-register/step2';
|
||||
static const String parentRegisterStep3 = '/parent-register/step3';
|
||||
static const String parentRegisterStep4 = '/parent-register/step4';
|
||||
static const String parentRegisterStep5 = '/parent-register/step5';
|
||||
static const String home = '/home';
|
||||
static const String adminDashboard = '/admin-dashboard';
|
||||
static const String parentDashboard = '/parent-dashboard';
|
||||
static const String amDashboard = '/am-dashboard';
|
||||
|
||||
static Route<dynamic> generateRoute(RouteSettings settings) {
|
||||
Widget screen;
|
||||
bool slideTransition = false;
|
||||
Object? args = settings.arguments;
|
||||
|
||||
Widget buildErrorScreen(String step) {
|
||||
print("Erreur: Données UserRegistrationData manquantes ou de mauvais type pour l'étape $step");
|
||||
return const ParentRegisterStep1Screen();
|
||||
}
|
||||
|
||||
switch (settings.name) {
|
||||
case login:
|
||||
screen = const LoginPage();
|
||||
break;
|
||||
case registerChoice:
|
||||
screen = const RegisterChoiceScreen();
|
||||
slideTransition = true;
|
||||
break;
|
||||
case parentRegisterStep1:
|
||||
screen = const ParentRegisterStep1Screen();
|
||||
slideTransition = true;
|
||||
break;
|
||||
case parentRegisterStep2:
|
||||
if (args is UserRegistrationData) {
|
||||
screen = ParentRegisterStep2Screen(registrationData: args);
|
||||
} else {
|
||||
screen = buildErrorScreen('2');
|
||||
}
|
||||
slideTransition = true;
|
||||
break;
|
||||
case parentRegisterStep3:
|
||||
if (args is UserRegistrationData) {
|
||||
screen = ParentRegisterStep3Screen(registrationData: args);
|
||||
} else {
|
||||
screen = buildErrorScreen('3');
|
||||
}
|
||||
slideTransition = true;
|
||||
break;
|
||||
case parentRegisterStep4:
|
||||
if (args is UserRegistrationData) {
|
||||
screen = ParentRegisterStep4Screen(registrationData: args);
|
||||
} else {
|
||||
screen = buildErrorScreen('4');
|
||||
}
|
||||
slideTransition = true;
|
||||
break;
|
||||
case parentRegisterStep5:
|
||||
if (args is UserRegistrationData) {
|
||||
screen = ParentRegisterStep5Screen(registrationData: args);
|
||||
} else {
|
||||
screen = buildErrorScreen('5');
|
||||
}
|
||||
slideTransition = true;
|
||||
break;
|
||||
case home:
|
||||
screen = const HomeScreen();
|
||||
break;
|
||||
case adminDashboard:
|
||||
screen = const AdminDashboardScreen();
|
||||
break;
|
||||
case parentDashboard:
|
||||
screen = const ParentDashboardScreen();
|
||||
break;
|
||||
case amDashboard:
|
||||
// TODO: Créer l'écran dashboard pour les assistantes maternelles
|
||||
screen = const HomeScreen();
|
||||
break;
|
||||
default:
|
||||
screen = Scaffold(
|
||||
body: Center(
|
||||
child: Text('Route non définie : ${settings.name}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (slideTransition) {
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => screen,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
|
||||
var offsetAnimation = animation.drive(tween);
|
||||
return SlideTransition(position: offsetAnimation, child: child);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
);
|
||||
} else {
|
||||
return MaterialPageRoute(builder: (_) => screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||
|
||||
@@ -9,20 +12,55 @@ class AdminDashboardScreen extends StatefulWidget {
|
||||
const AdminDashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
_AdminDashboardScreenState createState() => _AdminDashboardScreenState();
|
||||
State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
|
||||
}
|
||||
|
||||
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
int selectedIndex = 0;
|
||||
bool? _setupCompleted;
|
||||
int mainTabIndex = 0;
|
||||
int subIndex = 0;
|
||||
|
||||
void onTabChange(int index) {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSetupStatus();
|
||||
}
|
||||
|
||||
Future<void> _loadSetupStatus() async {
|
||||
try {
|
||||
final completed = await ConfigurationService.getSetupStatus();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setupCompleted = completed;
|
||||
if (!completed) mainTabIndex = 1;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() {
|
||||
_setupCompleted = false;
|
||||
mainTabIndex = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void onMainTabChange(int index) {
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
mainTabIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
void onSubTabChange(int index) {
|
||||
setState(() {
|
||||
subIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_setupCompleted == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60.0),
|
||||
@@ -33,13 +71,19 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
),
|
||||
),
|
||||
child: DashboardAppBarAdmin(
|
||||
selectedIndex: selectedIndex,
|
||||
onTabChange: onTabChange,
|
||||
selectedIndex: mainTabIndex,
|
||||
onTabChange: onMainTabChange,
|
||||
setupCompleted: _setupCompleted!,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (mainTabIndex == 0)
|
||||
DashboardUserManagementSubBar(
|
||||
selectedSubIndex: subIndex,
|
||||
onSubTabChange: onSubTabChange,
|
||||
),
|
||||
Expanded(
|
||||
child: _getBody(),
|
||||
),
|
||||
@@ -50,17 +94,20 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _getBody() {
|
||||
switch (selectedIndex) {
|
||||
if (mainTabIndex == 1) {
|
||||
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
|
||||
}
|
||||
switch (subIndex) {
|
||||
case 0:
|
||||
return const GestionnaireManagementWidget();
|
||||
return const GestionnaireManagementWidget();
|
||||
case 1:
|
||||
return const ParentManagementWidget();
|
||||
case 2:
|
||||
return const AssistanteMaternelleManagementWidget();
|
||||
case 3:
|
||||
return const Center(child: Text("👨💼 Administrateurs"));
|
||||
return const AdminManagementWidget();
|
||||
default:
|
||||
return const Center(child: Text("Page non trouvée"));
|
||||
return const Center(child: Text('Page non trouvée'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/am_user_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
import 'package:p_tits_pas/utils/data_generator.dart';
|
||||
import 'package:p_tits_pas/widgets/FormFieldConfig.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
|
||||
class AmRegisterStep1Screen extends StatefulWidget {
|
||||
const AmRegisterStep1Screen({super.key});
|
||||
@override
|
||||
State <AmRegisterStep1Screen> createState() => _AmRegisterStep1ScreenState();
|
||||
}
|
||||
|
||||
class _AmRegisterStep1ScreenState extends State<AmRegisterStep1Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late ChildminderRegistrationData _registrationData;
|
||||
|
||||
final _lastNameController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _addressController = TextEditingController();
|
||||
final _postalCodeController = TextEditingController();
|
||||
final _cityController = TextEditingController();
|
||||
|
||||
// File? _selectedImage;
|
||||
// bool _photoConsent = false;
|
||||
// final ImagePicker _picker = ImagePicker();
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = ChildminderRegistrationData();
|
||||
_generateAndFillData();
|
||||
}
|
||||
|
||||
void _generateAndFillData() {
|
||||
final String genFirstName = DataGenerator.firstName();
|
||||
final String genLastName = DataGenerator.lastName();
|
||||
final String genAddress = DataGenerator.address();
|
||||
final String genPostalCode = DataGenerator.postalCode();
|
||||
final String genCity = DataGenerator.city();
|
||||
final String genPhone = DataGenerator.phone();
|
||||
final String genEmail = DataGenerator.email(genFirstName, genLastName);
|
||||
final String genPassword = DataGenerator.password();
|
||||
|
||||
_addressController.text = genAddress;
|
||||
_postalCodeController.text = genPostalCode;
|
||||
_cityController.text = genCity;
|
||||
_firstNameController.text = genFirstName;
|
||||
_lastNameController.text = genLastName;
|
||||
_phoneController.text = genPhone;
|
||||
_emailController.text = genEmail;
|
||||
_passwordController.text = genPassword;
|
||||
_confirmPasswordController.text = genPassword;
|
||||
|
||||
setState(() {
|
||||
_registrationData.updateIdentity(
|
||||
ChildminderId(
|
||||
firstName: genFirstName,
|
||||
lastName: genLastName,
|
||||
address: genAddress,
|
||||
postalCode: genPostalCode,
|
||||
city: genCity,
|
||||
phone: genPhone,
|
||||
email: genEmail,
|
||||
password: genPassword.trim(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<List<ModularFormField>> get formFields => [
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Nom',
|
||||
hint: 'Votre nom de famille',
|
||||
controller: _lastNameController,
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Prénom',
|
||||
hint: 'Votre prénom',
|
||||
controller: _firstNameController,
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Téléphone',
|
||||
hint: 'Votre numéro de téléphone',
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
flex: 12,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Email',
|
||||
hint: 'Votre adresse email',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
flex: 12,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Mot de passe',
|
||||
hint: 'Votre mot de passe',
|
||||
controller: _passwordController,
|
||||
isPassword: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Mot de passe requis';
|
||||
if (value.length < 6) return '6 caractères minimum';
|
||||
return null;
|
||||
},
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Confirmer le mot de passe',
|
||||
hint: 'Confirmez votre mot de passe',
|
||||
controller: _confirmPasswordController,
|
||||
isPassword: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Mot de passe requis';
|
||||
if (value != _passwordController.text) return 'Les mots de passe ne correspondent pas';
|
||||
return null;
|
||||
},
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Adresse (N° et Rue)',
|
||||
hint: 'Numéro et nom de votre rue',
|
||||
controller: _addressController,
|
||||
isRequired: true,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Code postal',
|
||||
hint: 'Votre code postal',
|
||||
controller: _postalCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
isRequired: true,
|
||||
flex: 1,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Ville',
|
||||
hint: 'Votre ville',
|
||||
controller: _cityController,
|
||||
flex: 4,
|
||||
isRequired: true,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_registrationData.updateIdentity(
|
||||
ChildminderId(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
address: _addressController.text,
|
||||
postalCode: _postalCodeController.text,
|
||||
city: _cityController.text,
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
print('Vérification des données:');
|
||||
print('Adresse: ${_registrationData.identity.address}');
|
||||
print('Nom: ${_registrationData.identity.lastName}');
|
||||
print('Prénom: ${_registrationData.identity.firstName}');
|
||||
Navigator.pushNamed(context, '/am-register/step2',
|
||||
arguments: _registrationData);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Étape 1/4',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Informations d\'identité de l\'assistante maternelle',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(vertical: 50, horizontal: 50),
|
||||
constraints: const BoxConstraints(minHeight: 570),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.lavender.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: ModularForm(
|
||||
formKey: _formKey,
|
||||
fieldGroups: formFields,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _handleSubmit,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/am_user_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
import 'package:p_tits_pas/utils/data_generator.dart';
|
||||
import 'package:p_tits_pas/widgets/FormFieldConfig.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class AmRegisterStep2Screen extends StatefulWidget {
|
||||
final ChildminderRegistrationData registrationData;
|
||||
const AmRegisterStep2Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<AmRegisterStep2Screen> createState() => _AmRegisterStep2ScreenState();
|
||||
}
|
||||
|
||||
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late ChildminderRegistrationData _registrationData;
|
||||
|
||||
final _dateOfBirthController = TextEditingController();
|
||||
final _birthCityController = TextEditingController();
|
||||
final _birthCountryController = TextEditingController();
|
||||
final _socialSecurityController = TextEditingController();
|
||||
final _agreementNumberController = TextEditingController();
|
||||
final _maxChildrenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
_generateAndFillData();
|
||||
}
|
||||
|
||||
void _generateAndFillData() {
|
||||
_dateOfBirthController.text = DataGenerator.birthDate();
|
||||
_birthCityController.text = DataGenerator.city();
|
||||
_birthCountryController.text = "France";
|
||||
_socialSecurityController.text = DataGenerator.socialSecurityNumber();
|
||||
_agreementNumberController.text = DataGenerator.agreementNumber();
|
||||
_maxChildrenController.text = "3";
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dateOfBirthController.dispose();
|
||||
_birthCityController.dispose();
|
||||
_birthCountryController.dispose();
|
||||
_socialSecurityController.dispose();
|
||||
_agreementNumberController.dispose();
|
||||
_maxChildrenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateSocialSecurity(String? value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Numéro de sécurité sociale requis';
|
||||
|
||||
// Supprime les espaces pour la validation
|
||||
String cleanValue = value.replaceAll(' ', '');
|
||||
|
||||
// Vérifie que c'est bien 13 ou 15 chiffres
|
||||
if (cleanValue.length != 13 && cleanValue.length != 15) {
|
||||
return 'Format invalide (13 ou 15 chiffres)';
|
||||
}
|
||||
|
||||
if (!RegExp(r'^[0-9]+$').hasMatch(cleanValue)) {
|
||||
return 'Seuls les chiffres sont autorisés';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
List<List<ModularFormField>> get formFields => [
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Date de naissance',
|
||||
hint: 'JJ/MM/AAAA',
|
||||
controller: _dateOfBirthController,
|
||||
keyboardType: TextInputType.datetime,
|
||||
isRequired: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Date de naissance requise';
|
||||
// Validation basique du format de date
|
||||
if (!RegExp(r'^[0-3][0-9]/[0-1][0-9]/[1-2][0-9]{3}$')
|
||||
.hasMatch(value)) {
|
||||
return 'Format invalide (JJ/MM/AAAA)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
flex: 12,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Ville de naissance',
|
||||
hint: 'Votre ville de naissance',
|
||||
controller: _birthCityController,
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Pays de naissance',
|
||||
hint: 'Votre pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Numéro de Sécurité Sociale (NIR)',
|
||||
hint: '1234567890123',
|
||||
controller: _socialSecurityController,
|
||||
keyboardType: TextInputType.number,
|
||||
isRequired: true,
|
||||
validator: _validateSocialSecurity,
|
||||
),
|
||||
],
|
||||
[
|
||||
ModularFormField(
|
||||
label: 'Numéro d\'agrément',
|
||||
hint: 'Votre numéro d\'agrément',
|
||||
controller: _agreementNumberController,
|
||||
isRequired: true,
|
||||
flex: 12,
|
||||
),
|
||||
ModularFormField(
|
||||
label: 'Nombre d\'enfants max',
|
||||
hint: 'Ex: 3',
|
||||
controller: _maxChildrenController,
|
||||
keyboardType: TextInputType.number,
|
||||
isRequired: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Nombre requis';
|
||||
int? number = int.tryParse(value);
|
||||
if (number == null || number < 1 || number > 6) {
|
||||
return 'Entre 1 et 6 enfants';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
flex: 6,
|
||||
),
|
||||
],
|
||||
];
|
||||
void _handleSubmit() {
|
||||
print('Vérification des données2:');
|
||||
print('Adresse: ${_registrationData.identity.address}');
|
||||
print('Nom: ${_registrationData.identity.lastName}');
|
||||
print('Prénom: ${_registrationData.identity.firstName}');
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_registrationData.updateProfessional(
|
||||
ChildminderProfessional(
|
||||
dateOfBirth: _dateOfBirthController.text,
|
||||
birthCity: _birthCityController.text,
|
||||
birthCountry: _birthCountryController.text,
|
||||
socialSecurityNumber: _socialSecurityController.text,
|
||||
agreementNumber: _agreementNumberController.text,
|
||||
maxChildren: int.tryParse(_maxChildrenController.text) ?? 1,
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.pushNamed(context, '/am-register/step3',
|
||||
arguments: _registrationData);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Étape 2/4',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Informations professionnelles de l\'assistante maternelle',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 50, horizontal: 50),
|
||||
constraints: const BoxConstraints(minHeight: 570),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.peach.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: ModularForm(
|
||||
formKey: _formKey,
|
||||
fieldGroups: formFields,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child:
|
||||
Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _handleSubmit,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/am_user_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
import 'package:p_tits_pas/widgets/app_custom_checkbox.dart';
|
||||
import 'package:p_tits_pas/widgets/custom_decorated_text_field.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
|
||||
class AmRegisterStep3Screen extends StatefulWidget {
|
||||
final ChildminderRegistrationData registrationData;
|
||||
const AmRegisterStep3Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<AmRegisterStep3Screen> createState() => _AmRegisterStep3ScreenState();
|
||||
}
|
||||
|
||||
class _AmRegisterStep3ScreenState extends State<AmRegisterStep3Screen> {
|
||||
|
||||
late ChildminderRegistrationData _registrationData;
|
||||
final _presentationMessageController = TextEditingController();
|
||||
bool _cguAccepted = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
_presentationMessageController.text = _registrationData.presentationMessage;
|
||||
// _cguAccepted = _registrationData.cguAccepted;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_presentationMessageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showCGUModal() {
|
||||
const String loremIpsumText = '''
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna.
|
||||
|
||||
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
|
||||
|
||||
Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna. Etiam et felis dolor.
|
||||
|
||||
Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
|
||||
|
||||
Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.
|
||||
''';
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // L'utilisateur doit utiliser le bouton
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Conditions Générales d\'Utilisation',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(dialogContext).size.width * 0.7, // 70% de la largeur de l'écran
|
||||
height: MediaQuery.of(dialogContext).size.height * 0.6, // 60% de la hauteur de l'écran
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
loremIpsumText,
|
||||
style: GoogleFonts.merienda(fontSize: 13),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: <Widget>[
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).primaryColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
),
|
||||
child: Text(
|
||||
'Valider et Accepter',
|
||||
style: GoogleFonts.merienda(fontSize: 15, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Ferme la modale
|
||||
setState(() {
|
||||
_cguAccepted = true; // Met à jour l'état
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = screenSize.width * 0.6;
|
||||
final double imageAspectRatio = 2.0;
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 50.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Étape 3/4',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Message à destination du gestionnaire pour justifier votre demande ou ajouter des précisions',
|
||||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.green.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _presentationMessageController,
|
||||
hintText: 'Écrivez ici pour motiver votre demande...',
|
||||
fieldHeight: cardHeight * 0.6,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
}
|
||||
},
|
||||
child: AppCustomCheckbox(
|
||||
label: 'J\'accepte les conditions générales d\'utilisation',
|
||||
value: _cguAccepted,
|
||||
onChanged: (newValue) {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
} else {
|
||||
setState(() => _cguAccepted = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _cguAccepted
|
||||
? () {
|
||||
_registrationData.updatePresentation(_presentationMessageController.text);
|
||||
_registrationData.acceptCGU();
|
||||
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/am-register/step4',
|
||||
arguments: _registrationData
|
||||
);
|
||||
}
|
||||
: null,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/am_user_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
import 'package:p_tits_pas/widgets/Summary.dart';
|
||||
import 'package:p_tits_pas/widgets/custom_decorated_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/image_button.dart';
|
||||
|
||||
Widget _buildDisplayFieldValue(BuildContext context, String label, String value, {bool multiLine = false, double fieldHeight = 50.0, double labelFontSize = 18.0}) {
|
||||
const FontWeight labelFontWeight = FontWeight.w600;
|
||||
|
||||
// Ne pas afficher le label si labelFontSize est 0 ou si label est vide
|
||||
bool showLabel = label.isNotEmpty && labelFontSize > 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showLabel)
|
||||
Text(label, style: GoogleFonts.merienda(fontSize: labelFontSize, fontWeight: labelFontWeight)),
|
||||
if (showLabel)
|
||||
const SizedBox(height: 4),
|
||||
// Utiliser Expanded si multiLine et pas de hauteur fixe, sinon Container
|
||||
multiLine && fieldHeight == null
|
||||
? Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView( // Pour le défilement si le texte dépasse
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0), // Garder une taille de texte par défaut si label caché
|
||||
maxLines: null, // Permettre un nombre illimité de lignes
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: multiLine ? null : fieldHeight,
|
||||
constraints: multiLine ? BoxConstraints(minHeight: fieldHeight) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0),
|
||||
maxLines: multiLine ? null : 1,
|
||||
overflow: multiLine ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class AmRegisterStep4Screen extends StatelessWidget {
|
||||
final ChildminderRegistrationData registrationData;
|
||||
|
||||
const AmRegisterStep4Screen({super.key, required this.registrationData});
|
||||
|
||||
Widget _buildAm1Card(BuildContext context, ChildminderRegistrationData data) {
|
||||
const double verticalSpacing = 28.0; // Espacement vertical augmenté
|
||||
const double labelFontSize = 22.0; // Taille de label augmentée
|
||||
|
||||
List<Widget> details = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nom:", data.identity.lastName, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Prénom:", data.identity.firstName, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Téléphone:", data.identity.phone, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Email:", data.identity.email, multiLine: true, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Adresse:", "${data.identity.address}\n${data.identity.postalCode} ${data.identity.city}".trim(), labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
],
|
||||
),
|
||||
];
|
||||
return SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.peach.path,
|
||||
title: 'Informations d’identité',
|
||||
content: details,
|
||||
onEdit: () => Navigator.of(context).pushNamed('/am-register/step1', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAm2Card(BuildContext context, ChildminderRegistrationData data) {
|
||||
const double verticalSpacing = 28.0;
|
||||
const double labelFontSize = 22.0;
|
||||
|
||||
List<Widget> myDetails = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Date de naissance:", data.professional.dateOfBirth, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Ville de naissance:", data.professional.birthCity, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Pays de naissance:", data.professional.birthCountry, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Numéro de sécurité sociale:", data.professional.socialSecurityNumber, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Numéro d'agrément:", data.professional.agreementNumber, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nombre d'enfants maximum:", data.professional.maxChildren.toString(), labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
];
|
||||
return SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.lavender.path,
|
||||
title: 'Informations professionnelles',
|
||||
content: myDetails,
|
||||
onEdit: () => Navigator.of(context)
|
||||
.pushNamed('/am-register/step2', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMotivationCard(BuildContext context, ChildminderRegistrationData data) {
|
||||
return SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.green.path,
|
||||
title: 'Motivation',
|
||||
content: [
|
||||
Expanded(child: CustomDecoratedTextField(
|
||||
controller: TextEditingController(text: data.presentationMessage),
|
||||
hintText: 'Parlez-nous de votre motivation',
|
||||
fieldHeight: 200,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
readOnly: true,
|
||||
fontSize: 18.0,)),
|
||||
],
|
||||
onEdit: () => Navigator.of(context)
|
||||
.pushNamed('/am-register/step3', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeatY),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width / 4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('Etape 4/4',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 20),
|
||||
Text('Récapitulatif de votre demande',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 30),
|
||||
_buildAm1Card(context, registrationData),
|
||||
const SizedBox(height: 20),
|
||||
if (registrationData.professional != null) ...[
|
||||
_buildAm2Card(context, registrationData),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
_buildMotivationCard(context, registrationData),
|
||||
const SizedBox(height: 40),
|
||||
ImageButton(
|
||||
bg: 'assets/images/btn_green.png',
|
||||
text: 'Soumettre ma demande',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
width: 350,
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
onPressed: () {
|
||||
// Vérification des données requises
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform.flip(
|
||||
flipX: true,
|
||||
child: Image.asset('assets/images/chevron_right.png',
|
||||
height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Demande enregistrée',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Votre dossier a bien été pris en compte. Un gestionnaire le validera bientôt.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(context)
|
||||
.pushNamedAndRemoveUntil('/login', (route) => false);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class AmRegisterStep1Screen extends StatelessWidget {
|
||||
const AmRegisterStep1Screen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||
|
||||
// Générer des données de test si vide
|
||||
PersonalInfoData initialData;
|
||||
if (registrationData.firstName.isEmpty) {
|
||||
final genFirstName = DataGenerator.firstName();
|
||||
final genLastName = DataGenerator.lastName();
|
||||
initialData = PersonalInfoData(
|
||||
firstName: genFirstName,
|
||||
lastName: genLastName,
|
||||
phone: DataGenerator.phone(),
|
||||
email: DataGenerator.email(genFirstName, genLastName),
|
||||
address: DataGenerator.address(),
|
||||
postalCode: DataGenerator.postalCode(),
|
||||
city: DataGenerator.city(),
|
||||
);
|
||||
} else {
|
||||
initialData = PersonalInfoData(
|
||||
firstName: registrationData.firstName,
|
||||
lastName: registrationData.lastName,
|
||||
phone: registrationData.phone,
|
||||
email: registrationData.email,
|
||||
address: registrationData.streetAddress,
|
||||
postalCode: registrationData.postalCode,
|
||||
city: registrationData.city,
|
||||
);
|
||||
}
|
||||
|
||||
return PersonalInfoFormScreen(
|
||||
stepText: 'Étape 1/4',
|
||||
title: 'Vos informations personnelles',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: initialData,
|
||||
previousRoute: '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateIdentityInfo(
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
streetAddress: data.address,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
password: '',
|
||||
);
|
||||
context.go('/am-register-step2');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
import '../../widgets/professional_info_form_screen.dart';
|
||||
|
||||
class AmRegisterStep2Screen extends StatefulWidget {
|
||||
const AmRegisterStep2Screen({super.key});
|
||||
|
||||
@override
|
||||
State<AmRegisterStep2Screen> createState() => _AmRegisterStep2ScreenState();
|
||||
}
|
||||
|
||||
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
||||
String? _photoPathFramework;
|
||||
File? _photoFile;
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
// TODO: Remplacer par la vraie logique ImagePicker
|
||||
// final imagePicker = ImagePicker();
|
||||
// final pickedFile = await imagePicker.pickImage(source: ImageSource.gallery);
|
||||
// if (pickedFile != null) {
|
||||
// setState(() {
|
||||
// _photoFile = File(pickedFile.path);
|
||||
// _photoPathFramework = pickedFile.path;
|
||||
// });
|
||||
// } else {
|
||||
setState(() {
|
||||
_photoPathFramework = 'assets/images/icon_assmat.png';
|
||||
_photoFile = null;
|
||||
});
|
||||
// }
|
||||
print("Photo sélectionnée: $_photoPathFramework");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||
|
||||
// Préparer les données initiales
|
||||
ProfessionalInfoData initialData = ProfessionalInfoData(
|
||||
photoPath: registrationData.photoPath,
|
||||
photoConsent: registrationData.photoConsent,
|
||||
dateOfBirth: registrationData.dateOfBirth,
|
||||
birthCity: registrationData.birthCity,
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
capacity: registrationData.capacity,
|
||||
);
|
||||
|
||||
// Générer des données de test si les champs sont vides
|
||||
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
||||
initialData = ProfessionalInfoData(
|
||||
photoPath: 'assets/images/icon_assmat.png',
|
||||
photoConsent: true,
|
||||
dateOfBirth: DateTime(1985, 3, 15),
|
||||
birthCity: DataGenerator.city(),
|
||||
birthCountry: 'France',
|
||||
nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}',
|
||||
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
||||
capacity: DataGenerator.randomIntInRange(1, 5),
|
||||
);
|
||||
}
|
||||
|
||||
return ProfessionalInfoFormScreen(
|
||||
stepText: 'Étape 2/4',
|
||||
title: 'Vos informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: initialData,
|
||||
previousRoute: '/am-register-step1',
|
||||
onPickPhoto: _pickPhoto,
|
||||
onSubmit: (data) {
|
||||
registrationData.updateProfessionalInfo(
|
||||
photoPath: _photoPathFramework ?? data.photoPath,
|
||||
photoConsent: data.photoConsent,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
capacity: data.capacity,
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../widgets/presentation_form_screen.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class AmRegisterStep3Screen extends StatelessWidget {
|
||||
const AmRegisterStep3Screen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
||||
|
||||
// Générer un texte de test si vide
|
||||
String initialText = data.presentationText;
|
||||
bool initialCgu = data.cguAccepted;
|
||||
|
||||
if (initialText.isEmpty) {
|
||||
initialText = 'Disponible immédiatement, plus de 10 ans d\'expérience avec les tout-petits. Formation aux premiers secours à jour. Je dispose d\'un jardin sécurisé et d\'un espace de jeu adapté.';
|
||||
initialCgu = true;
|
||||
}
|
||||
|
||||
return PresentationFormScreen(
|
||||
stepText: 'Étape 3/4',
|
||||
title: 'Présentation et Conditions',
|
||||
cardColor: CardColorHorizontal.peach,
|
||||
textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...',
|
||||
initialText: initialText,
|
||||
initialCguAccepted: initialCgu,
|
||||
previousRoute: '/am-register-step2',
|
||||
onSubmit: (text, cguAccepted) {
|
||||
data.updatePresentationAndCgu(
|
||||
presentationText: text,
|
||||
cguAccepted: cguAccepted,
|
||||
);
|
||||
context.go('/am-register-step4');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../config/display_config.dart';
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../widgets/professional_info_form_screen.dart';
|
||||
import '../../widgets/presentation_form_screen.dart';
|
||||
|
||||
class AmRegisterStep4Screen extends StatefulWidget {
|
||||
const AmRegisterStep4Screen({super.key});
|
||||
|
||||
@override
|
||||
_AmRegisterStep4ScreenState createState() => _AmRegisterStep4ScreenState();
|
||||
}
|
||||
|
||||
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<AmRegistrationData>(context);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final config = DisplayConfig.fromContext(context, mode: DisplayMode.readonly);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeatY),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: config.isMobile ? 0 : screenSize.width / 4.0
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 4/4', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 20),
|
||||
Text('Récapitulatif de votre demande', style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Carte 1: Informations personnelles
|
||||
_buildPersonalInfo(context, registrationData),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Carte 2: Informations professionnelles
|
||||
_buildProfessionalInfo(context, registrationData),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Carte 3: Présentation
|
||||
_buildPresentation(context, registrationData),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Boutons Mobile (Retour + Soumettre) ou Bouton Soumettre Desktop
|
||||
if (config.isMobile)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/am-register-step3');
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Soumettre',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: () {
|
||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
ImageButton(
|
||||
bg: 'assets/images/bg_green.png',
|
||||
text: 'Soumettre ma demande',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
width: 350,
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
onPressed: () {
|
||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons desktop uniquement
|
||||
if (!config.isMobile)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/am-register-step3');
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonalInfo(BuildContext context, AmRegistrationData data) {
|
||||
return PersonalInfoFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Informations personnelles',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: PersonalInfoData(
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
address: data.streetAddress,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
),
|
||||
onSubmit: (d, {hasSecondPerson, sameAddress}) {}, // No-op en readonly
|
||||
previousRoute: '',
|
||||
onEdit: () => context.go('/am-register-step1'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfessionalInfo(BuildContext context, AmRegistrationData data) {
|
||||
return ProfessionalInfoFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: ProfessionalInfoData(
|
||||
// TODO: Gérer photoPath vs photoFile correctement
|
||||
photoPath: null, // Pas d'accès facile au fichier ici, on verra
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
capacity: data.capacity,
|
||||
photoConsent: data.photoConsent,
|
||||
),
|
||||
onSubmit: (d) {},
|
||||
previousRoute: '',
|
||||
onEdit: () => context.go('/am-register-step2'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPresentation(BuildContext context, AmRegistrationData data) {
|
||||
return PresentationFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Présentation & CGU',
|
||||
cardColor: CardColorHorizontal.peach,
|
||||
textFieldHint: '',
|
||||
initialText: data.presentationText,
|
||||
initialCguAccepted: data.cguAccepted,
|
||||
previousRoute: '',
|
||||
onSubmit: (t, c) {},
|
||||
onEdit: () => context.go('/am-register-step3'),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Demande enregistrée',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Votre dossier a bien été pris en compte. Un gestionnaire le validera bientôt.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:p_tits_pas/services/bug_report_service.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_app_text_field.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../widgets/auth/change_password_dialog.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
State<LoginScreen> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
@@ -24,13 +24,28 @@ class _LoginPageState extends State<LoginPage> {
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
static const double _mobileBreakpoint = 900.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
@@ -101,36 +116,42 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Redirige l'utilisateur selon son rôle
|
||||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||||
void _redirectUserByRole(String role) {
|
||||
setState(() => _isLoading = false);
|
||||
switch (role.toLowerCase()) {
|
||||
case 'super_admin':
|
||||
case 'administrateur':
|
||||
case 'gestionnaire':
|
||||
Navigator.pushReplacementNamed(context, '/admin-dashboard');
|
||||
context.go('/admin-dashboard');
|
||||
break;
|
||||
case 'parent':
|
||||
Navigator.pushReplacementNamed(context, '/parent-dashboard');
|
||||
context.go('/parent-dashboard');
|
||||
break;
|
||||
case 'assistante_maternelle':
|
||||
Navigator.pushReplacementNamed(context, '/am-dashboard');
|
||||
context.go('/am-dashboard');
|
||||
break;
|
||||
default:
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < _mobileBreakpoint;
|
||||
if (isMobile) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: _buildMobileLayout(context),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Version desktop (web)
|
||||
if (kIsWeb) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
|
||||
return FutureBuilder(
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
return FutureBuilder(
|
||||
future: _getImageDimensions(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
@@ -215,20 +236,20 @@ class _LoginPageState extends State<LoginPage> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.red[300]!),
|
||||
border: Border.all(color: Colors.red.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 12,
|
||||
color: Colors.red[700],
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -241,7 +262,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: ImageButton(
|
||||
bg: 'assets/images/btn_green.png',
|
||||
bg: 'assets/images/bg_green.png',
|
||||
width: 300,
|
||||
height: 40,
|
||||
text: 'Se connecter',
|
||||
@@ -266,12 +287,12 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Lien de création de compte
|
||||
const SizedBox(height: 20),
|
||||
// Lien de création de compte (version originale)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/register-choice');
|
||||
context.go('/register-choice');
|
||||
},
|
||||
child: Text(
|
||||
'Créer un compte',
|
||||
@@ -283,24 +304,24 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20), // Réduit l'espacement en bas
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Pied de page
|
||||
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FooterLink(
|
||||
text: 'Contact support',
|
||||
@@ -332,13 +353,13 @@ class _LoginPageState extends State<LoginPage> {
|
||||
_FooterLink(
|
||||
text: 'Mentions légales',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/legal');
|
||||
context.go('/legal');
|
||||
},
|
||||
),
|
||||
_FooterLink(
|
||||
text: 'Politique de confidentialité',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/privacy');
|
||||
context.go('/privacy');
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -349,17 +370,207 @@ class _LoginPageState extends State<LoginPage> {
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Version mobile (à implémenter)
|
||||
return const Center(
|
||||
child: Text('Version mobile à implémenter'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
||||
static const int _riverLogoMobileWidth = 600;
|
||||
static const int _riverLogoMobileHeight = 1080;
|
||||
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
||||
static const double _sloganEndFraction = 0.42;
|
||||
static const double _gapBelowSlogan = 12.0;
|
||||
|
||||
Widget _buildMobileLayout(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final h = constraints.maxHeight;
|
||||
final w = constraints.maxWidth;
|
||||
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
||||
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: h * 1.2,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minWidth: w,
|
||||
maxWidth: w,
|
||||
minHeight: 0,
|
||||
maxHeight: h * 2.5,
|
||||
child: Image.asset(
|
||||
'assets/images/river_logo_mobile.png',
|
||||
width: w,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: formTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppTextField(
|
||||
controller: _emailController,
|
||||
labelText: 'Email',
|
||||
showLabel: false,
|
||||
hintText: 'Votre adresse email',
|
||||
validator: _validateEmail,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomAppTextField(
|
||||
controller: _passwordController,
|
||||
labelText: 'Mot de passe',
|
||||
showLabel: false,
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
validator: _validatePassword,
|
||||
style: CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.red.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: ImageButton(
|
||||
bg: 'assets/images/bg_green.png',
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
text: 'Se connecter',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
onPressed: _handleLogin,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () { /* TODO */ },
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/register-choice'),
|
||||
child: Text(
|
||||
'Créer un compte',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 16,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runSpacing: 6,
|
||||
spacing: 4,
|
||||
children: [
|
||||
_FooterLink(
|
||||
text: 'Contact support',
|
||||
fontSize: 11,
|
||||
onTap: () async {
|
||||
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
_FooterLink(
|
||||
text: 'Signaler un bug',
|
||||
fontSize: 11,
|
||||
onTap: () => _showBugReportDialog(context),
|
||||
),
|
||||
_FooterLink(
|
||||
text: 'Mentions légales',
|
||||
fontSize: 11,
|
||||
onTap: () => context.go('/legal'),
|
||||
),
|
||||
_FooterLink(
|
||||
text: 'Politique de confidentialité',
|
||||
fontSize: 11,
|
||||
onTap: () => context.go('/privacy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showBugReportDialog(BuildContext context) {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
@@ -471,10 +682,12 @@ class ImageDimensions {
|
||||
class _FooterLink extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onTap;
|
||||
final double fontSize;
|
||||
|
||||
const _FooterLink({
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.fontSize = 14.0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -482,11 +695,11 @@ class _FooterLink extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
padding: EdgeInsets.symmetric(horizontal: fontSize > 12 ? 8.0 : 4.0),
|
||||
child: Text(
|
||||
text,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontSize: fontSize,
|
||||
color: Colors.black87,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import '../../../models/parent_user_registration_data.dart'; // Import du modèle de données
|
||||
import '../../../utils/data_generator.dart'; // Import du générateur de données
|
||||
import '../../../widgets/custom_app_text_field.dart'; // Import du widget CustomAppTextField
|
||||
import '../../../models/card_assets.dart'; // Import des enums de cartes
|
||||
|
||||
class ParentRegisterStep1Screen extends StatefulWidget {
|
||||
const ParentRegisterStep1Screen({super.key});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep1Screen> createState() => _ParentRegisterStep1ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep1ScreenState extends State<ParentRegisterStep1Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late UserRegistrationData _registrationData;
|
||||
|
||||
// Contrôleurs pour les champs (restauration CP et Ville)
|
||||
final _lastNameController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _addressController = TextEditingController();
|
||||
final _postalCodeController = TextEditingController();
|
||||
final _cityController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = UserRegistrationData();
|
||||
_generateAndFillData();
|
||||
}
|
||||
|
||||
void _generateAndFillData() {
|
||||
final String genFirstName = DataGenerator.firstName();
|
||||
final String genLastName = DataGenerator.lastName();
|
||||
|
||||
// Utilisation des méthodes publiques de DataGenerator
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
|
||||
_firstNameController.text = genFirstName;
|
||||
_lastNameController.text = genLastName;
|
||||
_phoneController.text = DataGenerator.phone();
|
||||
_emailController.text = DataGenerator.email(genFirstName, genLastName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Fond papier
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu centré
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Indicateur d'étape
|
||||
Text(
|
||||
'Étape 1/6',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Texte d'instruction
|
||||
Text(
|
||||
'Informations du Parent Principal',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Carte jaune contenant le formulaire
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(vertical: 50, horizontal: 50),
|
||||
constraints: const BoxConstraints(minHeight: 570),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.peach.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _lastNameController, labelText: 'Nom', hintText: 'Votre nom de famille', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _firstNameController, labelText: 'Prénom', hintText: 'Votre prénom', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _phoneController, labelText: 'Téléphone', keyboardType: TextInputType.phone, hintText: 'Votre numéro de téléphone', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _emailController, labelText: 'Email', keyboardType: TextInputType.emailAddress, hintText: 'Votre adresse e-mail', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
CustomAppTextField(
|
||||
controller: _addressController,
|
||||
labelText: 'Adresse (N° et Rue)',
|
||||
hintText: 'Numéro et nom de votre rue',
|
||||
style: CustomAppTextFieldStyle.beige,
|
||||
fieldWidth: double.infinity,
|
||||
labelFontSize: 22.0,
|
||||
inputFontSize: 20.0,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 1, child: CustomAppTextField(controller: _postalCodeController, labelText: 'Code Postal', keyboardType: TextInputType.number, hintText: 'Code postal', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(flex: 4, child: CustomAppTextField(controller: _cityController, labelText: 'Ville', hintText: 'Votre ville', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Chevron de navigation gauche (Retour)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20, // Centré verticalement
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi), // Inverse horizontalement
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context), // Retour à l'écran de choix
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
|
||||
// Chevron de navigation droit (Suivant)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20, // Centré verticalement
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_registrationData.updateParent1(
|
||||
ParentData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
address: _addressController.text,
|
||||
postalCode: _postalCodeController.text,
|
||||
city: _cityController.text,
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
password: '', // Pas de mot de passe à cette étape
|
||||
)
|
||||
);
|
||||
Navigator.pushNamed(context, '/parent-register/step2', arguments: _registrationData);
|
||||
}
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import '../../../models/parent_user_registration_data.dart'; // Import du modèle
|
||||
import '../../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../../widgets/custom_app_text_field.dart'; // Import du widget
|
||||
import '../../../models/card_assets.dart'; // Import des enums de cartes
|
||||
|
||||
class ParentRegisterStep2Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données de l'étape 1
|
||||
|
||||
const ParentRegisterStep2Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep2Screen> createState() => _ParentRegisterStep2ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep2ScreenState extends State<ParentRegisterStep2Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late UserRegistrationData _registrationData; // Copie locale pour modification
|
||||
|
||||
bool _addParent2 = true; // Pour le test, on ajoute toujours le parent 2
|
||||
bool _sameAddressAsParent1 = false; // Peut être généré aléatoirement aussi
|
||||
|
||||
// Contrôleurs pour les champs du parent 2
|
||||
final _lastNameController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _addressController = TextEditingController();
|
||||
final _postalCodeController = TextEditingController();
|
||||
final _cityController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData; // Récupère les données de l'étape 1
|
||||
if (_addParent2) {
|
||||
_generateAndFillParent2Data();
|
||||
}
|
||||
}
|
||||
|
||||
void _generateAndFillParent2Data() {
|
||||
final String genFirstName = DataGenerator.firstName();
|
||||
final String genLastName = DataGenerator.lastName();
|
||||
_firstNameController.text = genFirstName;
|
||||
_lastNameController.text = genLastName;
|
||||
_phoneController.text = DataGenerator.phone();
|
||||
_emailController.text = DataGenerator.email(genFirstName, genLastName);
|
||||
|
||||
_sameAddressAsParent1 = DataGenerator.boolean();
|
||||
if (!_sameAddressAsParent1) {
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
} else {
|
||||
_addressController.clear();
|
||||
_postalCodeController.clear();
|
||||
_cityController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _parent2FieldsEnabled => _addParent2;
|
||||
bool get _addressFieldsEnabled => _addParent2 && !_sameAddressAsParent1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 2/6', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Informations du Deuxième Parent (Optionnel)',
|
||||
style: GoogleFonts.merienda(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage(CardColorHorizontal.blue.path), fit: BoxFit.fill),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Row(children: [
|
||||
const Icon(Icons.person_add_alt_1, size: 20), const SizedBox(width: 8),
|
||||
Flexible(child: Text('Ajouter Parent 2 ?', style: GoogleFonts.merienda(fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis)),
|
||||
const Spacer(),
|
||||
Switch(value: _addParent2, onChanged: (val) => setState(() {
|
||||
_addParent2 = val ?? false;
|
||||
if (_addParent2) _generateAndFillParent2Data(); else _clearParent2Fields();
|
||||
}), activeColor: Theme.of(context).primaryColor),
|
||||
]),
|
||||
),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Row(children: [
|
||||
Icon(Icons.home_work_outlined, size: 20, color: _addParent2 ? null : Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text('Même Adresse ?', style: GoogleFonts.merienda(color: _addParent2 ? null : Colors.grey), overflow: TextOverflow.ellipsis)),
|
||||
const Spacer(),
|
||||
Switch(value: _sameAddressAsParent1, onChanged: _addParent2 ? (val) => setState(() {
|
||||
_sameAddressAsParent1 = val ?? false;
|
||||
if (_sameAddressAsParent1) {
|
||||
_addressController.text = _registrationData.parent1.address;
|
||||
_postalCodeController.text = _registrationData.parent1.postalCode;
|
||||
_cityController.text = _registrationData.parent1.city;
|
||||
} else {
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
}
|
||||
}) : null, activeColor: Theme.of(context).primaryColor),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _lastNameController, labelText: 'Nom', hintText: 'Nom du parent 2', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _firstNameController, labelText: 'Prénom', hintText: 'Prénom du parent 2', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _phoneController, labelText: 'Téléphone', keyboardType: TextInputType.phone, hintText: 'Son téléphone', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _emailController, labelText: 'Email', keyboardType: TextInputType.emailAddress, hintText: 'Son email', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
CustomAppTextField(controller: _addressController, labelText: 'Adresse (N° et Rue)', hintText: 'Son numéro et nom de rue', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 1, child: CustomAppTextField(controller: _postalCodeController, labelText: 'Code Postal', keyboardType: TextInputType.number, hintText: 'Son code postal', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(flex: 4, child: CustomAppTextField(controller: _cityController, labelText: 'Ville', hintText: 'Sa ville', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, labelFontSize: 22.0, inputFontSize: 20.0)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
if (!_addParent2 || (_formKey.currentState?.validate() ?? false)) {
|
||||
if (_addParent2) {
|
||||
_registrationData.updateParent2(
|
||||
ParentData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
address: _sameAddressAsParent1 ? _registrationData.parent1.address : _addressController.text,
|
||||
postalCode: _sameAddressAsParent1 ? _registrationData.parent1.postalCode : _postalCodeController.text,
|
||||
city: _sameAddressAsParent1 ? _registrationData.parent1.city : _cityController.text,
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
password: '', // Pas de mot de passe à cette étape
|
||||
)
|
||||
);
|
||||
} else {
|
||||
_registrationData.updateParent2(null);
|
||||
}
|
||||
Navigator.pushNamed(context, '/parent-register/step3', arguments: _registrationData);
|
||||
}
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _clearParent2Fields() {
|
||||
_formKey.currentState?.reset();
|
||||
_lastNameController.clear();
|
||||
_firstNameController.clear();
|
||||
_phoneController.clear();
|
||||
_emailController.clear();
|
||||
_addressController.clear();
|
||||
_postalCodeController.clear();
|
||||
_cityController.clear();
|
||||
_sameAddressAsParent1 = false;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import 'package:flutter/gestures.dart'; // Pour PointerDeviceKind
|
||||
import '../../../widgets/hover_relief_widget.dart'; // Import du nouveau widget
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
// import 'package:image_cropper/image_cropper.dart'; // Supprimé
|
||||
import 'dart:io' show File, Platform; // Ajout de Platform
|
||||
import 'package:flutter/foundation.dart' show kIsWeb; // Import pour kIsWeb
|
||||
import '../../../widgets/custom_app_text_field.dart'; // Import du nouveau widget TextField
|
||||
import '../../../widgets/app_custom_checkbox.dart'; // Import du nouveau widget Checkbox
|
||||
import '../../../models/parent_user_registration_data.dart'; // Import du modèle de données
|
||||
import '../../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../../models/card_assets.dart'; // Import des enums de cartes
|
||||
|
||||
// La classe _ChildFormData est supprimée car on utilise ChildData du modèle
|
||||
|
||||
class ParentRegisterStep3Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données
|
||||
|
||||
const ParentRegisterStep3Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep3Screen> createState() => _ParentRegisterStep3ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
late UserRegistrationData _registrationData; // Stocke l'état complet
|
||||
final ScrollController _scrollController = ScrollController(); // Pour le défilement horizontal
|
||||
bool _isScrollable = false;
|
||||
bool _showLeftFade = false;
|
||||
bool _showRightFade = false;
|
||||
static const double _fadeExtent = 0.05; // Pourcentage de fondu
|
||||
|
||||
// Liste ordonnée des couleurs de cartes pour les enfants
|
||||
static const List<CardColorVertical> _childCardColors = [
|
||||
CardColorVertical.lavender, // Premier enfant toujours lavande
|
||||
CardColorVertical.pink,
|
||||
CardColorVertical.peach,
|
||||
CardColorVertical.lime,
|
||||
CardColorVertical.red,
|
||||
CardColorVertical.green,
|
||||
CardColorVertical.blue,
|
||||
];
|
||||
|
||||
// Garder une trace des couleurs déjà utilisées
|
||||
final Set<CardColorVertical> _usedColors = {};
|
||||
|
||||
// Utilisation de GlobalKey pour les cartes enfants si validation complexe future
|
||||
// Map<int, GlobalKey<FormState>> _childFormKeys = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
// Initialiser les couleurs utilisées avec les enfants existants
|
||||
for (var child in _registrationData.children) {
|
||||
_usedColors.add(child.cardColor);
|
||||
}
|
||||
// S'il n'y a pas d'enfant, en ajouter un automatiquement avec des données générées
|
||||
if (_registrationData.children.isEmpty) {
|
||||
_addChild();
|
||||
}
|
||||
_scrollController.addListener(_scrollListener);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollListener() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final position = _scrollController.position;
|
||||
final newIsScrollable = position.maxScrollExtent > 0.0;
|
||||
final newShowLeftFade = newIsScrollable && position.pixels > (position.viewportDimension * _fadeExtent / 2);
|
||||
final newShowRightFade = newIsScrollable && position.pixels < (position.maxScrollExtent - (position.viewportDimension * _fadeExtent / 2));
|
||||
if (newIsScrollable != _isScrollable || newShowLeftFade != _showLeftFade || newShowRightFade != _showRightFade) {
|
||||
setState(() {
|
||||
_isScrollable = newIsScrollable;
|
||||
_showLeftFade = newShowLeftFade;
|
||||
_showRightFade = newShowRightFade;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _addChild() {
|
||||
setState(() {
|
||||
bool isUnborn = DataGenerator.boolean();
|
||||
|
||||
// Trouver la première couleur non utilisée
|
||||
CardColorVertical cardColor = _childCardColors.firstWhere(
|
||||
(color) => !_usedColors.contains(color),
|
||||
orElse: () => _childCardColors[0], // Fallback sur la première couleur si toutes sont utilisées
|
||||
);
|
||||
|
||||
final newChild = ChildData(
|
||||
lastName: _registrationData.parent1.lastName,
|
||||
firstName: DataGenerator.firstName(),
|
||||
dob: DataGenerator.dob(isUnborn: isUnborn),
|
||||
isUnbornChild: isUnborn,
|
||||
photoConsent: DataGenerator.boolean(),
|
||||
multipleBirth: DataGenerator.boolean(),
|
||||
cardColor: cardColor,
|
||||
);
|
||||
_registrationData.addChild(newChild);
|
||||
_usedColors.add(cardColor);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollListener();
|
||||
if (_scrollController.hasClients && _scrollController.position.maxScrollExtent > 0.0) {
|
||||
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _removeChild(int index) {
|
||||
if (_registrationData.children.length > 1 && index >= 0 && index < _registrationData.children.length) {
|
||||
setState(() {
|
||||
// Ne pas retirer la couleur de _usedColors pour éviter sa réutilisation
|
||||
_registrationData.children.removeAt(index);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(int childIndex) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? pickedFile = await picker.pickImage(
|
||||
source: ImageSource.gallery, imageQuality: 70, maxWidth: 1024, maxHeight: 1024);
|
||||
if (pickedFile != null) {
|
||||
setState(() {
|
||||
if (childIndex < _registrationData.children.length) {
|
||||
_registrationData.children[childIndex].imageFile = File(pickedFile.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { print("Erreur image: $e"); }
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context, int childIndex) async {
|
||||
final ChildData currentChild = _registrationData.children[childIndex];
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime initialDatePickerDate = now;
|
||||
DateTime firstDatePickerDate = DateTime(1980); DateTime lastDatePickerDate = now;
|
||||
|
||||
if (currentChild.isUnbornChild) {
|
||||
firstDatePickerDate = now; lastDatePickerDate = now.add(const Duration(days: 300));
|
||||
if (currentChild.dob.isNotEmpty) {
|
||||
try {
|
||||
List<String> parts = currentChild.dob.split('/');
|
||||
DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}");
|
||||
if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) {
|
||||
initialDatePickerDate = parsedDate;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
if (currentChild.dob.isNotEmpty) {
|
||||
try {
|
||||
List<String> parts = currentChild.dob.split('/');
|
||||
DateTime? parsedDate = DateTime.tryParse("${parts[2]}-${parts[1].padLeft(2, '0')}-${parts[0].padLeft(2, '0')}");
|
||||
if (parsedDate != null && !parsedDate.isBefore(firstDatePickerDate) && !parsedDate.isAfter(lastDatePickerDate)) {
|
||||
initialDatePickerDate = parsedDate;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context, initialDate: initialDatePickerDate, firstDate: firstDatePickerDate,
|
||||
lastDate: lastDatePickerDate, locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
currentChild.dob = "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 3/5', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Informations Enfants',
|
||||
style: GoogleFonts.merienda(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 150.0),
|
||||
child: SizedBox(
|
||||
height: 684.0,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
final Color leftFade = (_isScrollable && _showLeftFade) ? Colors.transparent : Colors.black;
|
||||
final Color rightFade = (_isScrollable && _showRightFade) ? Colors.transparent : Colors.black;
|
||||
if (!_isScrollable) { return LinearGradient(colors: const <Color>[Colors.black, Colors.black, Colors.black, Colors.black], stops: const [0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0],).createShader(bounds); }
|
||||
return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: <Color>[ leftFade, Colors.black, Colors.black, rightFade ], stops: const [0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0], ).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
thumbVisibility: true,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
itemCount: _registrationData.children.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _registrationData.children.length) {
|
||||
// Carte Enfant
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 20.0),
|
||||
child: _ChildCardWidget(
|
||||
key: ValueKey(_registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
|
||||
childData: _registrationData.children[index],
|
||||
childIndex: index,
|
||||
onPickImage: () => _pickImage(index),
|
||||
onDateSelect: () => _selectDate(context, index),
|
||||
onFirstNameChanged: (value) => setState(() => _registrationData.children[index].firstName = value),
|
||||
onLastNameChanged: (value) => setState(() => _registrationData.children[index].lastName = value),
|
||||
onTogglePhotoConsent: (newValue) => setState(() => _registrationData.children[index].photoConsent = newValue),
|
||||
onToggleMultipleBirth: (newValue) => setState(() => _registrationData.children[index].multipleBirth = newValue),
|
||||
onToggleIsUnborn: (newValue) => setState(() {
|
||||
_registrationData.children[index].isUnbornChild = newValue;
|
||||
// Générer une nouvelle date si on change le statut
|
||||
_registrationData.children[index].dob = DataGenerator.dob(isUnborn: newValue);
|
||||
}),
|
||||
onRemove: () => _removeChild(index),
|
||||
canBeRemoved: _registrationData.children.length > 1,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Bouton Ajouter
|
||||
return Center(
|
||||
child: HoverReliefWidget(
|
||||
onPressed: _addChild,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: Image.asset('assets/images/plus.png', height: 80, width: 80),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
// TODO: Validation (si nécessaire)
|
||||
Navigator.pushNamed(context, '/parent-register/step4', arguments: _registrationData);
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Widget pour la carte enfant (adapté pour prendre ChildData et des callbacks)
|
||||
class _ChildCardWidget extends StatefulWidget { // Transformé en StatefulWidget pour gérer les contrôleurs internes
|
||||
final ChildData childData;
|
||||
final int childIndex;
|
||||
final VoidCallback onPickImage;
|
||||
final VoidCallback onDateSelect;
|
||||
final ValueChanged<String> onFirstNameChanged;
|
||||
final ValueChanged<String> onLastNameChanged;
|
||||
final ValueChanged<bool> onTogglePhotoConsent;
|
||||
final ValueChanged<bool> onToggleMultipleBirth;
|
||||
final ValueChanged<bool> onToggleIsUnborn;
|
||||
final VoidCallback onRemove;
|
||||
final bool canBeRemoved;
|
||||
|
||||
const _ChildCardWidget({
|
||||
required Key key,
|
||||
required this.childData,
|
||||
required this.childIndex,
|
||||
required this.onPickImage,
|
||||
required this.onDateSelect,
|
||||
required this.onFirstNameChanged,
|
||||
required this.onLastNameChanged,
|
||||
required this.onTogglePhotoConsent,
|
||||
required this.onToggleMultipleBirth,
|
||||
required this.onToggleIsUnborn,
|
||||
required this.onRemove,
|
||||
required this.canBeRemoved,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_ChildCardWidget> createState() => _ChildCardWidgetState();
|
||||
}
|
||||
|
||||
class _ChildCardWidgetState extends State<_ChildCardWidget> {
|
||||
late TextEditingController _firstNameController;
|
||||
late TextEditingController _lastNameController;
|
||||
late TextEditingController _dobController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les contrôleurs avec les données du widget
|
||||
_firstNameController = TextEditingController(text: widget.childData.firstName);
|
||||
_lastNameController = TextEditingController(text: widget.childData.lastName);
|
||||
_dobController = TextEditingController(text: widget.childData.dob);
|
||||
|
||||
// Ajouter des listeners pour mettre à jour les données sources via les callbacks
|
||||
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
|
||||
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
|
||||
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ChildCardWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Mettre à jour les contrôleurs si les données externes changent
|
||||
// (peut arriver si on recharge l'état global)
|
||||
if (widget.childData.firstName != _firstNameController.text) {
|
||||
_firstNameController.text = widget.childData.firstName;
|
||||
}
|
||||
if (widget.childData.lastName != _lastNameController.text) {
|
||||
_lastNameController.text = widget.childData.lastName;
|
||||
}
|
||||
if (widget.childData.dob != _dobController.text) {
|
||||
_dobController.text = widget.childData.dob;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_dobController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
// Utiliser la couleur de la carte de childData pour l'ombre si besoin, ou directement pour le fond
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); // Placeholder pour autres couleurs
|
||||
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
|
||||
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
|
||||
|
||||
return Container(
|
||||
width: 345.0 * 1.1, // 379.5
|
||||
height: 570.0 * 1.2, // 684.0
|
||||
padding: const EdgeInsets.all(22.0 * 1.1), // 24.2
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage(widget.childData.cardColor.path), fit: BoxFit.cover),
|
||||
borderRadius: BorderRadius.circular(20 * 1.1), // 22
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: widget.onPickImage,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
child: SizedBox(
|
||||
height: 200.0,
|
||||
width: 200.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0 * 1.1), // 5.5
|
||||
child: currentChildImage != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(10 * 1.1), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12.0 * 1.1), // Augmenté pour plus d'espace après la photo
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Enfant à naître ?', style: GoogleFonts.merienda(fontSize: 16 * 1.1, fontWeight: FontWeight.w600)),
|
||||
Switch(value: widget.childData.isUnbornChild, onChanged: widget.onToggleIsUnborn, activeColor: Theme.of(context).primaryColor),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 9.0 * 1.1), // 9.9
|
||||
CustomAppTextField(
|
||||
controller: _firstNameController,
|
||||
labelText: 'Prénom',
|
||||
hintText: 'Facultatif si à naître',
|
||||
isRequired: !widget.childData.isUnbornChild,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 6.0 * 1.1), // 6.6
|
||||
CustomAppTextField(
|
||||
controller: _lastNameController,
|
||||
labelText: 'Nom',
|
||||
hintText: 'Nom de l\'enfant',
|
||||
enabled: true,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 9.0 * 1.1), // 9.9
|
||||
CustomAppTextField(
|
||||
controller: _dobController,
|
||||
labelText: widget.childData.isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
|
||||
hintText: 'JJ/MM/AAAA',
|
||||
readOnly: true,
|
||||
onTap: widget.onDateSelect,
|
||||
suffixIcon: Icons.calendar_today,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 11.0 * 1.1), // 12.1
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Consentement photo',
|
||||
value: widget.childData.photoConsent,
|
||||
onChanged: widget.onTogglePhotoConsent,
|
||||
checkboxSize: 22.0 * 1.1, // 24.2
|
||||
),
|
||||
const SizedBox(height: 6.0 * 1.1), // 6.6
|
||||
AppCustomCheckbox(
|
||||
label: 'Naissance multiple',
|
||||
value: widget.childData.multipleBirth,
|
||||
onChanged: widget.onToggleMultipleBirth,
|
||||
checkboxSize: 22.0 * 1.1, // 24.2
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.canBeRemoved)
|
||||
Positioned(
|
||||
top: -5, right: -5,
|
||||
child: InkWell(
|
||||
onTap: widget.onRemove,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Image.asset(
|
||||
'images/red_cross2.png',
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/widgets/custom_decorated_text_field.dart'; // Import du nouveau widget
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import 'package:p_tits_pas/widgets/app_custom_checkbox.dart'; // Import de la checkbox personnalisée
|
||||
// import 'package:p_tits_pas/models/placeholder_registration_data.dart'; // Remplacé
|
||||
import '../../../models/parent_user_registration_data.dart'; // Import du vrai modèle
|
||||
import '../../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../../models/card_assets.dart'; // Import des enums de cartes
|
||||
|
||||
class ParentRegisterStep4Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données
|
||||
|
||||
const ParentRegisterStep4Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep4Screen> createState() => _ParentRegisterStep4ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep4ScreenState extends State<ParentRegisterStep4Screen> {
|
||||
late UserRegistrationData _registrationData; // État local
|
||||
final _motivationController = TextEditingController();
|
||||
bool _cguAccepted = true; // Pour le test, CGU acceptées par défaut
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
_motivationController.text = DataGenerator.motivation(); // Générer la motivation
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motivationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showCGUModal() {
|
||||
// Un long texte Lorem Ipsum pour simuler les CGU
|
||||
const String loremIpsumText = '''
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna.
|
||||
|
||||
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
|
||||
|
||||
Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna. Etiam et felis dolor.
|
||||
|
||||
Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
|
||||
|
||||
Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.
|
||||
''';
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // L'utilisateur doit utiliser le bouton
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Conditions Générales d\'Utilisation',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(dialogContext).size.width * 0.7, // 70% de la largeur de l'écran
|
||||
height: MediaQuery.of(dialogContext).size.height * 0.6, // 60% de la hauteur de l'écran
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
loremIpsumText,
|
||||
style: GoogleFonts.merienda(fontSize: 13),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: <Widget>[
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).primaryColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
),
|
||||
child: Text(
|
||||
'Valider et Accepter',
|
||||
style: GoogleFonts.merienda(fontSize: 15, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Ferme la modale
|
||||
setState(() {
|
||||
_cguAccepted = true; // Met à jour l'état
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = screenSize.width * 0.6; // Largeur de la carte (60% de l'écran)
|
||||
final double imageAspectRatio = 2.0; // Ratio corrigé (1024/512 = 2.0)
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 50.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Étape 4/5',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Motivation de votre demande',
|
||||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.green.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _motivationController,
|
||||
hintText: 'Écrivez ici pour motiver votre demande...',
|
||||
fieldHeight: cardHeight * 0.6,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
}
|
||||
},
|
||||
child: AppCustomCheckbox(
|
||||
label: 'J\'accepte les conditions générales d\'utilisation',
|
||||
value: _cguAccepted,
|
||||
onChanged: (newValue) {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
} else {
|
||||
setState(() => _cguAccepted = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _cguAccepted
|
||||
? () {
|
||||
_registrationData.updateMotivation(_motivationController.text);
|
||||
_registrationData.acceptCGU();
|
||||
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/parent-register/step5',
|
||||
arguments: _registrationData
|
||||
);
|
||||
}
|
||||
: null,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/widgets/Summary.dart';
|
||||
import '../../../models/parent_user_registration_data.dart'; // Utilisation du vrai modèle
|
||||
import '../../../widgets/image_button.dart'; // Import du ImageButton
|
||||
import '../../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../../widgets/custom_decorated_text_field.dart'; // Import du CustomDecoratedTextField
|
||||
|
||||
// Nouvelle méthode helper pour afficher un champ de type "lecture seule" stylisé
|
||||
Widget _buildDisplayFieldValue(BuildContext context, String label, String value, {bool multiLine = false, double fieldHeight = 50.0, double labelFontSize = 18.0}) {
|
||||
const FontWeight labelFontWeight = FontWeight.w600;
|
||||
|
||||
// Ne pas afficher le label si labelFontSize est 0 ou si label est vide
|
||||
bool showLabel = label.isNotEmpty && labelFontSize > 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showLabel)
|
||||
Text(label, style: GoogleFonts.merienda(fontSize: labelFontSize, fontWeight: labelFontWeight)),
|
||||
if (showLabel)
|
||||
const SizedBox(height: 4),
|
||||
// Utiliser Expanded si multiLine et pas de hauteur fixe, sinon Container
|
||||
multiLine && fieldHeight == null
|
||||
? Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView( // Pour le défilement si le texte dépasse
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0), // Garder une taille de texte par défaut si label caché
|
||||
maxLines: null, // Permettre un nombre illimité de lignes
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: multiLine ? null : fieldHeight,
|
||||
constraints: multiLine ? BoxConstraints(minHeight: fieldHeight) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0),
|
||||
maxLines: multiLine ? null : 1,
|
||||
overflow: multiLine ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
final UserRegistrationData registrationData;
|
||||
|
||||
const ParentRegisterStep5Screen({super.key, required this.registrationData});
|
||||
|
||||
// Méthode pour construire la carte Parent 1
|
||||
Widget _buildParent1Card(BuildContext context, ParentData data) {
|
||||
const double verticalSpacing = 28.0; // Espacement vertical augmenté
|
||||
const double labelFontSize = 22.0; // Taille de label augmentée
|
||||
|
||||
List<Widget> details = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nom:", data.lastName, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Prénom:", data.firstName, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Téléphone:", data.phone, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Email:", data.email, multiLine: true, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
_buildDisplayFieldValue(context, "Adresse:", "${data.address}\n${data.postalCode} ${data.city}".trim(), multiLine: true, fieldHeight: 80, labelFontSize: labelFontSize),
|
||||
];
|
||||
return _SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.peach.path,
|
||||
title: 'Parent Principal',
|
||||
content: details,
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step1', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour construire la carte Parent 2
|
||||
Widget _buildParent2Card(BuildContext context, ParentData data) {
|
||||
const double verticalSpacing = 28.0;
|
||||
const double labelFontSize = 22.0;
|
||||
List<Widget> details = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nom:", data.lastName, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Prénom:", data.firstName, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Téléphone:", data.phone, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Email:", data.email, multiLine: true, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
_buildDisplayFieldValue(context, "Adresse:", "${data.address}\n${data.postalCode} ${data.city}".trim(), multiLine: true, fieldHeight: 80, labelFontSize: labelFontSize),
|
||||
];
|
||||
return SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.blue.path,
|
||||
title: 'Deuxième Parent',
|
||||
content: details,
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step2', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour construire les cartes Enfants
|
||||
List<Widget> _buildChildrenCards(BuildContext context, List<ChildData> children) {
|
||||
return children.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
ChildData child = entry.value;
|
||||
|
||||
CardColorHorizontal cardColorHorizontal = CardColorHorizontal.values.firstWhere(
|
||||
(e) => e.name == child.cardColor.name,
|
||||
orElse: () => CardColorHorizontal.lavender,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(cardColorHorizontal.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre centré dans la carte
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enfant ${index + 1}' + (child.isUnbornChild ? ' (à naître)' : ''),
|
||||
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
'/parent-register/step3',
|
||||
arguments: registrationData,
|
||||
);
|
||||
},
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// IMAGE SANS CADRE BLANC, PREND LA HAUTEUR
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: (child.imageFile != null)
|
||||
? (kIsWeb
|
||||
? Image.network(child.imageFile!.path, fit: BoxFit.cover)
|
||||
: Image.file(child.imageFile!, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
// INFOS À DROITE (2/3)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildDisplayFieldValue(context, 'Prénom :', child.firstName, labelFontSize: 22.0),
|
||||
const SizedBox(height: 12),
|
||||
_buildDisplayFieldValue(context, 'Nom :', child.lastName, labelFontSize: 22.0),
|
||||
const SizedBox(height: 12),
|
||||
_buildDisplayFieldValue(context, child.isUnbornChild ? 'Date de naissance :' : 'Date de naissance :', child.dob, labelFontSize: 22.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// Ligne des consentements
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: child.photoConsent,
|
||||
onChanged: null,
|
||||
),
|
||||
Text('Consentement photo', style: GoogleFonts.merienda(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: child.multipleBirth,
|
||||
onChanged: null,
|
||||
),
|
||||
Text('Naissance multiple', style: GoogleFonts.merienda(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Méthode pour construire la carte Motivation
|
||||
Widget _buildMotivationCard(BuildContext context, String motivation) {
|
||||
return _SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.green.path,
|
||||
title: 'Votre Motivation',
|
||||
content: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: TextEditingController(text: motivation),
|
||||
hintText: 'Aucune motivation renseignée.',
|
||||
fieldHeight: 200,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
readOnly: true,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step4', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour afficher une ligne de détail (police et agencement amélioré)
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"$label: ",
|
||||
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: 18),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = screenSize.width / 2.0; // Largeur de la carte (50% de l'écran)
|
||||
final double imageAspectRatio = 2.0; // Ratio corrigé (1024/512 = 2.0)
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeatY),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0), // Padding horizontal supprimé ici
|
||||
child: Padding( // Ajout du Padding horizontal externe
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width / 4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 5/5', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 20),
|
||||
Text('Récapitulatif de votre demande', style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_buildParent1Card(context, registrationData.parent1),
|
||||
const SizedBox(height: 20),
|
||||
if (registrationData.parent2 != null) ...[
|
||||
_buildParent2Card(context, registrationData.parent2!),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
..._buildChildrenCards(context, registrationData.children),
|
||||
_buildMotivationCard(context, registrationData.motivationText),
|
||||
const SizedBox(height: 40),
|
||||
ImageButton(
|
||||
bg: 'assets/images/btn_green.png',
|
||||
text: 'Soumettre ma demande',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
width: 350,
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
onPressed: () {
|
||||
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform.flip(flipX: true, child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context), // Retour à l'étape 4
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Demande enregistrée',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Votre dossier a bien été pris en compte. Un gestionnaire le validera bientôt.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Ferme la modale
|
||||
// TODO: Naviguer vers l'écran de connexion ou tableau de bord
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/login', (Route<dynamic> route) => false);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Widget générique _SummaryCard (ajusté)
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String backgroundImagePath;
|
||||
final String title;
|
||||
final List<Widget> content;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const _SummaryCard({
|
||||
super.key,
|
||||
required this.backgroundImagePath,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(backgroundImagePath),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import '../../models/user_registration_data.dart'; // Import du modèle de données
|
||||
import '../../utils/data_generator.dart'; // Import du générateur de données
|
||||
import '../../widgets/custom_app_text_field.dart'; // Import du widget CustomAppTextField
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ParentRegisterStep1Screen extends StatefulWidget {
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class ParentRegisterStep1Screen extends StatelessWidget {
|
||||
const ParentRegisterStep1Screen({super.key});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep1Screen> createState() => _ParentRegisterStep1ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep1ScreenState extends State<ParentRegisterStep1Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late UserRegistrationData _registrationData;
|
||||
|
||||
// Contrôleurs pour les champs (restauration CP et Ville)
|
||||
final _lastNameController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _addressController = TextEditingController(); // Rue seule
|
||||
final _postalCodeController = TextEditingController(); // Restauré
|
||||
final _cityController = TextEditingController(); // Restauré
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = UserRegistrationData();
|
||||
_generateAndFillData();
|
||||
}
|
||||
|
||||
void _generateAndFillData() {
|
||||
final String genFirstName = DataGenerator.firstName();
|
||||
final String genLastName = DataGenerator.lastName();
|
||||
|
||||
// Utilisation des méthodes publiques de DataGenerator
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
|
||||
_firstNameController.text = genFirstName;
|
||||
_lastNameController.text = genLastName;
|
||||
_phoneController.text = DataGenerator.phone();
|
||||
_emailController.text = DataGenerator.email(genFirstName, genLastName);
|
||||
_passwordController.text = DataGenerator.password();
|
||||
_confirmPasswordController.text = _passwordController.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
|
||||
final parent1 = registrationData.parent1;
|
||||
|
||||
// Générer des données de test si vide
|
||||
PersonalInfoData initialData;
|
||||
if (parent1.firstName.isEmpty) {
|
||||
final genFirstName = DataGenerator.firstName();
|
||||
final genLastName = DataGenerator.lastName();
|
||||
initialData = PersonalInfoData(
|
||||
firstName: genFirstName,
|
||||
lastName: genLastName,
|
||||
phone: DataGenerator.phone(),
|
||||
email: DataGenerator.email(genFirstName, genLastName),
|
||||
address: DataGenerator.address(),
|
||||
postalCode: DataGenerator.postalCode(),
|
||||
city: DataGenerator.city(),
|
||||
);
|
||||
} else {
|
||||
initialData = PersonalInfoData(
|
||||
firstName: parent1.firstName,
|
||||
lastName: parent1.lastName,
|
||||
phone: parent1.phone,
|
||||
email: parent1.email,
|
||||
address: parent1.address,
|
||||
postalCode: parent1.postalCode,
|
||||
city: parent1.city,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Fond papier
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu centré
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Indicateur d'étape (à rendre dynamique)
|
||||
Text(
|
||||
'Étape 1/5',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Texte d'instruction
|
||||
Text(
|
||||
'Informations du Parent Principal',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Carte jaune contenant le formulaire
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(vertical: 50, horizontal: 50),
|
||||
constraints: const BoxConstraints(minHeight: 570),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.peach.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _lastNameController, labelText: 'Nom', hintText: 'Votre nom de famille', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _firstNameController, labelText: 'Prénom', hintText: 'Votre prénom', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _phoneController, labelText: 'Téléphone', keyboardType: TextInputType.phone, hintText: 'Votre numéro de téléphone', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _emailController, labelText: 'Email', keyboardType: TextInputType.emailAddress, hintText: 'Votre adresse e-mail', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _passwordController, labelText: 'Mot de passe', obscureText: true, hintText: 'Créez votre mot de passe', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Mot de passe requis';
|
||||
if (value.length < 6) return '6 caractères minimum';
|
||||
return null;
|
||||
})),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _confirmPasswordController, labelText: 'Confirmation', obscureText: true, hintText: 'Confirmez le mot de passe', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Confirmation requise';
|
||||
if (value != _passwordController.text) return 'Ne correspond pas';
|
||||
return null;
|
||||
})),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
CustomAppTextField(
|
||||
controller: _addressController,
|
||||
labelText: 'Adresse (N° et Rue)',
|
||||
hintText: 'Numéro et nom de votre rue',
|
||||
style: CustomAppTextFieldStyle.beige,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 1, child: CustomAppTextField(controller: _postalCodeController, labelText: 'Code Postal', keyboardType: TextInputType.number, hintText: 'Code postal', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(flex: 4, child: CustomAppTextField(controller: _cityController, labelText: 'Ville', hintText: 'Votre ville', style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Chevron de navigation gauche (Retour)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20, // Centré verticalement
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi), // Inverse horizontalement
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context), // Retour à l'écran de choix
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
|
||||
// Chevron de navigation droit (Suivant)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20, // Centré verticalement
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_registrationData.updateParent1(
|
||||
ParentData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
address: _addressController.text, // Rue
|
||||
postalCode: _postalCodeController.text, // Ajout
|
||||
city: _cityController.text, // Ajout
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
)
|
||||
);
|
||||
Navigator.pushNamed(context, '/parent-register/step2', arguments: _registrationData);
|
||||
}
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return PersonalInfoFormScreen(
|
||||
stepText: 'Étape 1/5',
|
||||
title: 'Informations du Parent Principal',
|
||||
cardColor: CardColorHorizontal.peach,
|
||||
initialData: initialData,
|
||||
previousRoute: '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateParent1(ParentData(
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
address: data.address,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
password: '',
|
||||
));
|
||||
context.go('/parent-register-step2');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,255 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import '../../models/user_registration_data.dart'; // Import du modèle
|
||||
import '../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../widgets/custom_app_text_field.dart'; // Import du widget
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ParentRegisterStep2Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données de l'étape 1
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
const ParentRegisterStep2Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep2Screen> createState() => _ParentRegisterStep2ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep2ScreenState extends State<ParentRegisterStep2Screen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late UserRegistrationData _registrationData; // Copie locale pour modification
|
||||
|
||||
bool _addParent2 = true; // Pour le test, on ajoute toujours le parent 2
|
||||
bool _sameAddressAsParent1 = false; // Peut être généré aléatoirement aussi
|
||||
|
||||
// Contrôleurs pour les champs du parent 2 (restauration CP et Ville)
|
||||
final _lastNameController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _addressController = TextEditingController(); // Rue seule
|
||||
final _postalCodeController = TextEditingController(); // Restauré
|
||||
final _cityController = TextEditingController(); // Restauré
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData; // Récupère les données de l'étape 1
|
||||
if (_addParent2) {
|
||||
_generateAndFillParent2Data();
|
||||
}
|
||||
}
|
||||
|
||||
void _generateAndFillParent2Data() {
|
||||
final String genFirstName = DataGenerator.firstName();
|
||||
final String genLastName = DataGenerator.lastName();
|
||||
_firstNameController.text = genFirstName;
|
||||
_lastNameController.text = genLastName;
|
||||
_phoneController.text = DataGenerator.phone();
|
||||
_emailController.text = DataGenerator.email(genFirstName, genLastName);
|
||||
_passwordController.text = DataGenerator.password();
|
||||
_confirmPasswordController.text = _passwordController.text;
|
||||
|
||||
_sameAddressAsParent1 = DataGenerator.boolean();
|
||||
if (!_sameAddressAsParent1) {
|
||||
// Générer adresse, CP, Ville séparément
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
} else {
|
||||
// Vider les champs si même adresse (seront désactivés)
|
||||
_addressController.clear();
|
||||
_postalCodeController.clear();
|
||||
_cityController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _parent2FieldsEnabled => _addParent2;
|
||||
bool get _addressFieldsEnabled => _addParent2 && !_sameAddressAsParent1;
|
||||
class ParentRegisterStep2Screen extends StatelessWidget {
|
||||
const ParentRegisterStep2Screen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
|
||||
final parent1 = registrationData.parent1;
|
||||
final parent2 = registrationData.parent2;
|
||||
|
||||
bool hasParent2 = parent2 != null;
|
||||
bool sameAddress = false;
|
||||
|
||||
// Générer des données de test si vide
|
||||
PersonalInfoData initialData;
|
||||
if (parent2 == null || parent2.firstName.isEmpty) {
|
||||
final genFirstName = DataGenerator.firstName();
|
||||
final genLastName = DataGenerator.lastName();
|
||||
sameAddress = DataGenerator.boolean();
|
||||
|
||||
initialData = PersonalInfoData(
|
||||
firstName: genFirstName,
|
||||
lastName: genLastName,
|
||||
phone: DataGenerator.phone(),
|
||||
email: DataGenerator.email(genFirstName, genLastName),
|
||||
address: sameAddress ? parent1.address : DataGenerator.address(),
|
||||
postalCode: sameAddress ? parent1.postalCode : DataGenerator.postalCode(),
|
||||
city: sameAddress ? parent1.city : DataGenerator.city(),
|
||||
);
|
||||
} else {
|
||||
sameAddress = (parent2.address == parent1.address &&
|
||||
parent2.postalCode == parent1.postalCode &&
|
||||
parent2.city == parent1.city);
|
||||
initialData = PersonalInfoData(
|
||||
firstName: parent2.firstName,
|
||||
lastName: parent2.lastName,
|
||||
phone: parent2.phone,
|
||||
email: parent2.email,
|
||||
address: parent2.address,
|
||||
postalCode: parent2.postalCode,
|
||||
city: parent2.city,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 2/5', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Informations du Deuxième Parent (Optionnel)',
|
||||
style: GoogleFonts.merienda(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: screenSize.width * 0.6,
|
||||
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage(CardColorHorizontal.blue.path), fit: BoxFit.fill),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Row(children: [
|
||||
const Icon(Icons.person_add_alt_1, size: 20), const SizedBox(width: 8),
|
||||
Flexible(child: Text('Ajouter Parent 2 ?', style: GoogleFonts.merienda(fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis)),
|
||||
const Spacer(),
|
||||
Switch(value: _addParent2, onChanged: (val) => setState(() {
|
||||
_addParent2 = val ?? false;
|
||||
if (_addParent2) _generateAndFillParent2Data(); else _clearParent2Fields();
|
||||
}), activeColor: Theme.of(context).primaryColor),
|
||||
]),
|
||||
),
|
||||
Expanded(flex: 1, child: const SizedBox()),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Row(children: [
|
||||
Icon(Icons.home_work_outlined, size: 20, color: _addParent2 ? null : Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text('Même Adresse ?', style: GoogleFonts.merienda(color: _addParent2 ? null : Colors.grey), overflow: TextOverflow.ellipsis)),
|
||||
const Spacer(),
|
||||
Switch(value: _sameAddressAsParent1, onChanged: _addParent2 ? (val) => setState(() {
|
||||
_sameAddressAsParent1 = val ?? false;
|
||||
if (_sameAddressAsParent1) {
|
||||
_addressController.text = _registrationData.parent1.address;
|
||||
_postalCodeController.text = _registrationData.parent1.postalCode;
|
||||
_cityController.text = _registrationData.parent1.city;
|
||||
} else {
|
||||
_addressController.text = DataGenerator.address();
|
||||
_postalCodeController.text = DataGenerator.postalCode();
|
||||
_cityController.text = DataGenerator.city();
|
||||
}
|
||||
}) : null, activeColor: Theme.of(context).primaryColor),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 25),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _lastNameController, labelText: 'Nom', hintText: 'Nom du parent 2', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _firstNameController, labelText: 'Prénom', hintText: 'Prénom du parent 2', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _phoneController, labelText: 'Téléphone', keyboardType: TextInputType.phone, hintText: 'Son téléphone', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _emailController, labelText: 'Email', keyboardType: TextInputType.emailAddress, hintText: 'Son email', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _passwordController, labelText: 'Mot de passe', obscureText: true, hintText: 'Son mot de passe', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, validator: _addParent2 ? (v) => (v == null || v.isEmpty ? 'Requis' : (v.length < 6 ? '6 car. min' : null)) : null)),
|
||||
Expanded(flex: 1, child: const SizedBox()), // Espace de 4%
|
||||
Expanded(flex: 12, child: CustomAppTextField(controller: _confirmPasswordController, labelText: 'Confirmation', obscureText: true, hintText: 'Confirmer mot de passe', enabled: _parent2FieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity, validator: _addParent2 ? (v) => (v == null || v.isEmpty ? 'Requis' : (v != _passwordController.text ? 'Différent' : null)) : null)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
CustomAppTextField(controller: _addressController, labelText: 'Adresse (N° et Rue)', hintText: 'Son numéro et nom de rue', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 1, child: CustomAppTextField(controller: _postalCodeController, labelText: 'Code Postal', keyboardType: TextInputType.number, hintText: 'Son code postal', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(flex: 4, child: CustomAppTextField(controller: _cityController, labelText: 'Ville', hintText: 'Sa ville', enabled: _addressFieldsEnabled, style: CustomAppTextFieldStyle.beige, fieldWidth: double.infinity)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
if (!_addParent2 || (_formKey.currentState?.validate() ?? false)) {
|
||||
if (_addParent2) {
|
||||
_registrationData.updateParent2(
|
||||
ParentData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
address: _sameAddressAsParent1 ? _registrationData.parent1.address : _addressController.text,
|
||||
postalCode: _sameAddressAsParent1 ? _registrationData.parent1.postalCode : _postalCodeController.text,
|
||||
city: _sameAddressAsParent1 ? _registrationData.parent1.city : _cityController.text,
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
_registrationData.updateParent2(null);
|
||||
}
|
||||
Navigator.pushNamed(context, '/parent-register/step3', arguments: _registrationData);
|
||||
}
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Adresse de référence pour "même adresse"
|
||||
final referenceAddress = PersonalInfoData(
|
||||
address: parent1.address,
|
||||
postalCode: parent1.postalCode,
|
||||
city: parent1.city,
|
||||
);
|
||||
|
||||
return PersonalInfoFormScreen(
|
||||
stepText: 'Étape 2/5',
|
||||
title: 'Deuxième Parent',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: initialData,
|
||||
previousRoute: '/parent-register-step1',
|
||||
showSecondPersonToggle: true,
|
||||
initialHasSecondPerson: hasParent2,
|
||||
showSameAddressCheckbox: true,
|
||||
initialSameAddress: sameAddress,
|
||||
referenceAddressData: referenceAddress,
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
if (hasSecondPerson == true) {
|
||||
registrationData.updateParent2(ParentData(
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
address: data.address,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
password: '',
|
||||
));
|
||||
} else {
|
||||
registrationData.updateParent2(null);
|
||||
}
|
||||
context.go('/parent-register-step3');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearParent2Fields() {
|
||||
_formKey.currentState?.reset();
|
||||
_lastNameController.clear(); _firstNameController.clear(); _phoneController.clear();
|
||||
_emailController.clear(); _passwordController.clear(); _confirmPasswordController.clear();
|
||||
_addressController.clear();
|
||||
_postalCodeController.clear();
|
||||
_cityController.clear();
|
||||
_sameAddressAsParent1 = false;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import 'package:flutter/gestures.dart'; // Pour PointerDeviceKind
|
||||
import '../../widgets/hover_relief_widget.dart'; // Import du nouveau widget
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
// import 'package:image_cropper/image_cropper.dart'; // Supprimé
|
||||
import 'dart:io' show File, Platform; // Ajout de Platform
|
||||
import 'package:flutter/foundation.dart' show kIsWeb; // Import pour kIsWeb
|
||||
import '../../widgets/custom_app_text_field.dart'; // Import du nouveau widget TextField
|
||||
import '../../widgets/app_custom_checkbox.dart'; // Import du nouveau widget Checkbox
|
||||
import '../../models/user_registration_data.dart'; // Import du modèle de données
|
||||
import '../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
|
||||
// La classe _ChildFormData est supprimée car on utilise ChildData du modèle
|
||||
import 'dart:io' show File;
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/child_card_widget.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../config/display_config.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ParentRegisterStep3Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données
|
||||
// final UserRegistrationData registrationData; // Supprimé
|
||||
|
||||
const ParentRegisterStep3Screen({super.key, required this.registrationData});
|
||||
const ParentRegisterStep3Screen({super.key /*, required this.registrationData */}); // Modifié
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep3Screen> createState() => _ParentRegisterStep3ScreenState();
|
||||
_ParentRegisterStep3ScreenState createState() =>
|
||||
_ParentRegisterStep3ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
late UserRegistrationData _registrationData; // Stocke l'état complet
|
||||
// late UserRegistrationData _registrationData; // Supprimé
|
||||
final ScrollController _scrollController = ScrollController(); // Pour le défilement horizontal
|
||||
bool _isScrollable = false;
|
||||
bool _showLeftFade = false;
|
||||
@@ -52,14 +51,18 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
|
||||
// _registrationData = registrationData; // Supprimé
|
||||
|
||||
// Initialiser les couleurs utilisées avec les enfants existants
|
||||
for (var child in _registrationData.children) {
|
||||
for (var child in registrationData.children) {
|
||||
_usedColors.add(child.cardColor);
|
||||
}
|
||||
// S'il n'y a pas d'enfant, en ajouter un automatiquement avec des données générées
|
||||
if (_registrationData.children.isEmpty) {
|
||||
_addChild();
|
||||
// S'il n'y a pas d'enfant, en ajouter un automatiquement APRÈS le premier build
|
||||
if (registrationData.children.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_addChild(registrationData);
|
||||
});
|
||||
}
|
||||
_scrollController.addListener(_scrollListener);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
|
||||
@@ -87,7 +90,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _addChild() {
|
||||
void _addChild(UserRegistrationData registrationData) { // Prend registrationData
|
||||
setState(() {
|
||||
bool isUnborn = DataGenerator.boolean();
|
||||
|
||||
@@ -98,7 +101,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
);
|
||||
|
||||
final newChild = ChildData(
|
||||
lastName: _registrationData.parent1.lastName,
|
||||
lastName: registrationData.parent1.lastName,
|
||||
firstName: DataGenerator.firstName(),
|
||||
dob: DataGenerator.dob(isUnborn: isUnborn),
|
||||
isUnbornChild: isUnborn,
|
||||
@@ -106,7 +109,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
multipleBirth: DataGenerator.boolean(),
|
||||
cardColor: cardColor,
|
||||
);
|
||||
_registrationData.addChild(newChild);
|
||||
registrationData.addChild(newChild);
|
||||
_usedColors.add(cardColor);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -117,33 +120,42 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _removeChild(int index) {
|
||||
if (_registrationData.children.length > 1 && index >= 0 && index < _registrationData.children.length) {
|
||||
void _removeChild(int index, UserRegistrationData registrationData) {
|
||||
if (registrationData.children.length > 1 && index >= 0 && index < registrationData.children.length) {
|
||||
setState(() {
|
||||
// Ne pas retirer la couleur de _usedColors pour éviter sa réutilisation
|
||||
_registrationData.children.removeAt(index);
|
||||
registrationData.children.removeAt(index);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollListener());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(int childIndex) async {
|
||||
Future<void> _pickImage(int childIndex, UserRegistrationData registrationData) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? pickedFile = await picker.pickImage(
|
||||
source: ImageSource.gallery, imageQuality: 70, maxWidth: 1024, maxHeight: 1024);
|
||||
if (pickedFile != null) {
|
||||
setState(() {
|
||||
if (childIndex < _registrationData.children.length) {
|
||||
_registrationData.children[childIndex].imageFile = File(pickedFile.path);
|
||||
}
|
||||
});
|
||||
if (childIndex < registrationData.children.length) {
|
||||
final oldChild = registrationData.children[childIndex];
|
||||
final updatedChild = ChildData(
|
||||
firstName: oldChild.firstName,
|
||||
lastName: oldChild.lastName,
|
||||
dob: oldChild.dob,
|
||||
photoConsent: oldChild.photoConsent,
|
||||
multipleBirth: oldChild.multipleBirth,
|
||||
isUnbornChild: oldChild.isUnbornChild,
|
||||
imageFile: File(pickedFile.path),
|
||||
cardColor: oldChild.cardColor,
|
||||
);
|
||||
registrationData.updateChild(childIndex, updatedChild);
|
||||
}
|
||||
}
|
||||
} catch (e) { print("Erreur image: $e"); }
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context, int childIndex) async {
|
||||
final ChildData currentChild = _registrationData.children[childIndex];
|
||||
Future<void> _selectDate(BuildContext context, int childIndex, UserRegistrationData registrationData) async {
|
||||
final ChildData currentChild = registrationData.children[childIndex];
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime initialDatePickerDate = now;
|
||||
DateTime firstDatePickerDate = DateTime(1980); DateTime lastDatePickerDate = now;
|
||||
@@ -175,23 +187,176 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
lastDate: lastDatePickerDate, locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
currentChild.dob = "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}";
|
||||
});
|
||||
final oldChild = registrationData.children[childIndex];
|
||||
final updatedChild = ChildData(
|
||||
firstName: oldChild.firstName,
|
||||
lastName: oldChild.lastName,
|
||||
dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}",
|
||||
photoConsent: oldChild.photoConsent,
|
||||
multipleBirth: oldChild.multipleBirth,
|
||||
isUnbornChild: oldChild.isUnbornChild,
|
||||
imageFile: oldChild.imageFile,
|
||||
cardColor: oldChild.cardColor,
|
||||
);
|
||||
registrationData.updateChild(childIndex, updatedChild);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<UserRegistrationData>(context /*, listen: true par défaut */);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final config = DisplayConfig.fromContext(context, mode: DisplayMode.editable);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
config.isMobile
|
||||
? _buildMobileLayout(context, config, screenSize, registrationData)
|
||||
: _buildDesktopLayout(context, config, screenSize, registrationData),
|
||||
// Chevrons desktop uniquement
|
||||
if (!config.isMobile) ...[
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/parent-register-step2');
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
context.go('/parent-register-step4');
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout MOBILE : Cartes empilées verticalement
|
||||
Widget _buildMobileLayout(BuildContext context, DisplayConfig config, Size screenSize, UserRegistrationData registrationData) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header fixe
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Étape 3/5',
|
||||
style: GoogleFonts.merienda(fontSize: 13, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Informations Enfants',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Liste scrollable des cartes + boutons
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Column(
|
||||
children: [
|
||||
// Générer les cartes enfants
|
||||
for (int index = 0; index < registrationData.children.length; index++) ...[
|
||||
ChildCardWidget(
|
||||
key: ValueKey(registrationData.children[index].hashCode),
|
||||
childData: registrationData.children[index],
|
||||
childIndex: index,
|
||||
onPickImage: () => _pickImage(index, registrationData),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
|
||||
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
|
||||
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
|
||||
))),
|
||||
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
|
||||
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
|
||||
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
|
||||
))),
|
||||
onTogglePhotoConsent: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
|
||||
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onToggleMultipleBirth: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
|
||||
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onToggleIsUnborn: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
|
||||
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
|
||||
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onRemove: () => _removeChild(index, registrationData),
|
||||
canBeRemoved: registrationData.children.length > 1,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Bouton "+" carré à la fin de la liste
|
||||
Center(
|
||||
child: HoverReliefWidget(
|
||||
onPressed: () => _addChild(registrationData),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: Image.asset('assets/images/plus.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
// Boutons navigation en bas du scroll
|
||||
_buildMobileButtons(context, config, screenSize),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout DESKTOP : Scroll horizontal avec fondu
|
||||
Widget _buildDesktopLayout(BuildContext context, DisplayConfig config, Size screenSize, UserRegistrationData registrationData) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Étape 3/5', style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
|
||||
@@ -221,38 +386,59 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
itemCount: _registrationData.children.length + 1,
|
||||
itemCount: registrationData.children.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _registrationData.children.length) {
|
||||
if (index < registrationData.children.length) {
|
||||
// Carte Enfant
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 20.0),
|
||||
child: _ChildCardWidget(
|
||||
key: ValueKey(_registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
|
||||
childData: _registrationData.children[index],
|
||||
child: ChildCardWidget(
|
||||
key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
|
||||
childData: registrationData.children[index],
|
||||
childIndex: index,
|
||||
onPickImage: () => _pickImage(index),
|
||||
onDateSelect: () => _selectDate(context, index),
|
||||
onFirstNameChanged: (value) => setState(() => _registrationData.children[index].firstName = value),
|
||||
onLastNameChanged: (value) => setState(() => _registrationData.children[index].lastName = value),
|
||||
onTogglePhotoConsent: (newValue) => setState(() => _registrationData.children[index].photoConsent = newValue),
|
||||
onToggleMultipleBirth: (newValue) => setState(() => _registrationData.children[index].multipleBirth = newValue),
|
||||
onToggleIsUnborn: (newValue) => setState(() {
|
||||
_registrationData.children[index].isUnbornChild = newValue;
|
||||
// Générer une nouvelle date si on change le statut
|
||||
_registrationData.children[index].dob = DataGenerator.dob(isUnborn: newValue);
|
||||
}),
|
||||
onRemove: () => _removeChild(index),
|
||||
canBeRemoved: _registrationData.children.length > 1,
|
||||
onPickImage: () => _pickImage(index, registrationData),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
|
||||
firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
|
||||
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
|
||||
))),
|
||||
onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData(
|
||||
firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent,
|
||||
multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor
|
||||
))),
|
||||
onTogglePhotoConsent: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue,
|
||||
multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onToggleMultipleBirth: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent,
|
||||
multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onToggleIsUnborn: (newValue) {
|
||||
final oldChild = registrationData.children[index];
|
||||
registrationData.updateChild(index, ChildData(
|
||||
firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue),
|
||||
photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue,
|
||||
imageFile: oldChild.imageFile, cardColor: oldChild.cardColor
|
||||
));
|
||||
},
|
||||
onRemove: () => _removeChild(index, registrationData),
|
||||
canBeRemoved: registrationData.children.length > 1,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Bouton Ajouter
|
||||
// Bouton Ajouter Desktop (Gros bouton)
|
||||
return Center(
|
||||
child: HoverReliefWidget(
|
||||
onPressed: _addChild,
|
||||
onPressed: () => _addChild(registrationData),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: Image.asset('assets/images/plus.png', height: 80, width: 80),
|
||||
child: Image.asset('assets/images/plus.png', height: 100, width: 100, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -265,223 +451,47 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: () {
|
||||
// TODO: Validation (si nécessaire)
|
||||
Navigator.pushNamed(context, '/parent-register/step4', arguments: _registrationData);
|
||||
},
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Widget pour la carte enfant (adapté pour prendre ChildData et des callbacks)
|
||||
class _ChildCardWidget extends StatefulWidget { // Transformé en StatefulWidget pour gérer les contrôleurs internes
|
||||
final ChildData childData;
|
||||
final int childIndex;
|
||||
final VoidCallback onPickImage;
|
||||
final VoidCallback onDateSelect;
|
||||
final ValueChanged<String> onFirstNameChanged;
|
||||
final ValueChanged<String> onLastNameChanged;
|
||||
final ValueChanged<bool> onTogglePhotoConsent;
|
||||
final ValueChanged<bool> onToggleMultipleBirth;
|
||||
final ValueChanged<bool> onToggleIsUnborn;
|
||||
final VoidCallback onRemove;
|
||||
final bool canBeRemoved;
|
||||
|
||||
const _ChildCardWidget({
|
||||
required Key key,
|
||||
required this.childData,
|
||||
required this.childIndex,
|
||||
required this.onPickImage,
|
||||
required this.onDateSelect,
|
||||
required this.onFirstNameChanged,
|
||||
required this.onLastNameChanged,
|
||||
required this.onTogglePhotoConsent,
|
||||
required this.onToggleMultipleBirth,
|
||||
required this.onToggleIsUnborn,
|
||||
required this.onRemove,
|
||||
required this.canBeRemoved,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_ChildCardWidget> createState() => _ChildCardWidgetState();
|
||||
}
|
||||
|
||||
class _ChildCardWidgetState extends State<_ChildCardWidget> {
|
||||
late TextEditingController _firstNameController;
|
||||
late TextEditingController _lastNameController;
|
||||
late TextEditingController _dobController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les contrôleurs avec les données du widget
|
||||
_firstNameController = TextEditingController(text: widget.childData.firstName);
|
||||
_lastNameController = TextEditingController(text: widget.childData.lastName);
|
||||
_dobController = TextEditingController(text: widget.childData.dob);
|
||||
|
||||
// Ajouter des listeners pour mettre à jour les données sources via les callbacks
|
||||
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
|
||||
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
|
||||
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ChildCardWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Mettre à jour les contrôleurs si les données externes changent
|
||||
// (peut arriver si on recharge l'état global)
|
||||
if (widget.childData.firstName != _firstNameController.text) {
|
||||
_firstNameController.text = widget.childData.firstName;
|
||||
}
|
||||
if (widget.childData.lastName != _lastNameController.text) {
|
||||
_lastNameController.text = widget.childData.lastName;
|
||||
}
|
||||
if (widget.childData.dob != _dobController.text) {
|
||||
_dobController.text = widget.childData.dob;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_dobController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
// Utiliser la couleur de la carte de childData pour l'ombre si besoin, ou directement pour le fond
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); // Placeholder pour autres couleurs
|
||||
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
|
||||
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
|
||||
|
||||
return Container(
|
||||
width: 345.0 * 1.1, // 379.5
|
||||
height: 570.0 * 1.2, // 684.0
|
||||
padding: const EdgeInsets.all(22.0 * 1.1), // 24.2
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage(widget.childData.cardColor.path), fit: BoxFit.cover),
|
||||
borderRadius: BorderRadius.circular(20 * 1.1), // 22
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: widget.onPickImage,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
child: SizedBox(
|
||||
height: 200.0,
|
||||
width: 200.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0 * 1.1), // 5.5
|
||||
child: currentChildImage != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(10 * 1.1), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12.0 * 1.1), // Augmenté pour plus d'espace après la photo
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Enfant à naître ?', style: GoogleFonts.merienda(fontSize: 16 * 1.1, fontWeight: FontWeight.w600)),
|
||||
Switch(value: widget.childData.isUnbornChild, onChanged: widget.onToggleIsUnborn, activeColor: Theme.of(context).primaryColor),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 9.0 * 1.1), // 9.9
|
||||
CustomAppTextField(
|
||||
controller: _firstNameController,
|
||||
labelText: 'Prénom',
|
||||
hintText: 'Facultatif si à naître',
|
||||
isRequired: !widget.childData.isUnbornChild,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 6.0 * 1.1), // 6.6
|
||||
CustomAppTextField(
|
||||
controller: _lastNameController,
|
||||
labelText: 'Nom',
|
||||
hintText: 'Nom de l\'enfant',
|
||||
enabled: true,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 9.0 * 1.1), // 9.9
|
||||
CustomAppTextField(
|
||||
controller: _dobController,
|
||||
labelText: widget.childData.isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
|
||||
hintText: 'JJ/MM/AAAA',
|
||||
readOnly: true,
|
||||
onTap: widget.onDateSelect,
|
||||
suffixIcon: Icons.calendar_today,
|
||||
fieldHeight: 55.0 * 1.1, // 60.5
|
||||
),
|
||||
const SizedBox(height: 11.0 * 1.1), // 12.1
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Consentement photo',
|
||||
value: widget.childData.photoConsent,
|
||||
onChanged: widget.onTogglePhotoConsent,
|
||||
checkboxSize: 22.0 * 1.1, // 24.2
|
||||
),
|
||||
const SizedBox(height: 6.0 * 1.1), // 6.6
|
||||
AppCustomCheckbox(
|
||||
label: 'Naissance multiple',
|
||||
value: widget.childData.multipleBirth,
|
||||
onChanged: widget.onToggleMultipleBirth,
|
||||
checkboxSize: 22.0 * 1.1, // 24.2
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.canBeRemoved)
|
||||
Positioned(
|
||||
top: -5, right: -5,
|
||||
child: InkWell(
|
||||
onTap: widget.onRemove,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Image.asset(
|
||||
'images/red_cross2.png',
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
/// Boutons navigation mobile
|
||||
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/parent-register-step2');
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Suivant',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: () {
|
||||
context.go('/parent-register-step4');
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/widgets/custom_decorated_text_field.dart'; // Import du nouveau widget
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import 'package:p_tits_pas/widgets/app_custom_checkbox.dart'; // Import de la checkbox personnalisée
|
||||
// import 'package:p_tits_pas/models/placeholder_registration_data.dart'; // Remplacé
|
||||
import '../../models/user_registration_data.dart'; // Import du vrai modèle
|
||||
import '../../utils/data_generator.dart'; // Import du générateur
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ParentRegisterStep4Screen extends StatefulWidget {
|
||||
final UserRegistrationData registrationData; // Accepte les données
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../widgets/presentation_form_screen.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../utils/data_generator.dart';
|
||||
|
||||
const ParentRegisterStep4Screen({super.key, required this.registrationData});
|
||||
|
||||
@override
|
||||
State<ParentRegisterStep4Screen> createState() => _ParentRegisterStep4ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep4ScreenState extends State<ParentRegisterStep4Screen> {
|
||||
late UserRegistrationData _registrationData; // État local
|
||||
final _motivationController = TextEditingController();
|
||||
bool _cguAccepted = true; // Pour le test, CGU acceptées par défaut
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registrationData = widget.registrationData;
|
||||
_motivationController.text = DataGenerator.motivation(); // Générer la motivation
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motivationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showCGUModal() {
|
||||
// Un long texte Lorem Ipsum pour simuler les CGU
|
||||
const String loremIpsumText = '''
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna.
|
||||
|
||||
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet.
|
||||
|
||||
Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.
|
||||
|
||||
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna. Etiam et felis dolor.
|
||||
|
||||
Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
|
||||
|
||||
Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.
|
||||
''';
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // L'utilisateur doit utiliser le bouton
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Conditions Générales d\'Utilisation',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(dialogContext).size.width * 0.7, // 70% de la largeur de l'écran
|
||||
height: MediaQuery.of(dialogContext).size.height * 0.6, // 60% de la hauteur de l'écran
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
loremIpsumText,
|
||||
style: GoogleFonts.merienda(fontSize: 13),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: <Widget>[
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).primaryColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
),
|
||||
child: Text(
|
||||
'Valider et Accepter',
|
||||
style: GoogleFonts.merienda(fontSize: 15, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Ferme la modale
|
||||
setState(() {
|
||||
_cguAccepted = true; // Met à jour l'état
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
class ParentRegisterStep4Screen extends StatelessWidget {
|
||||
const ParentRegisterStep4Screen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = screenSize.width * 0.6; // Largeur de la carte (60% de l'écran)
|
||||
final double imageAspectRatio = 2.0; // Ratio corrigé (1024/512 = 2.0)
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
final registrationData = Provider.of<UserRegistrationData>(context, listen: false);
|
||||
|
||||
// Générer un texte de test si vide
|
||||
String initialText = registrationData.motivationText;
|
||||
bool initialCgu = registrationData.cguAccepted;
|
||||
|
||||
if (initialText.isEmpty) {
|
||||
initialText = DataGenerator.motivation();
|
||||
initialCgu = true;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 50.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Étape 4/5',
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Motivation de votre demande',
|
||||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorHorizontal.green.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _motivationController,
|
||||
hintText: 'Écrivez ici pour motiver votre demande...',
|
||||
fieldHeight: cardHeight * 0.6,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
}
|
||||
},
|
||||
child: AppCustomCheckbox(
|
||||
label: 'J\'accepte les conditions générales d\'utilisation',
|
||||
value: _cguAccepted,
|
||||
onChanged: (newValue) {
|
||||
if (!_cguAccepted) {
|
||||
_showCGUModal();
|
||||
} else {
|
||||
setState(() => _cguAccepted = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(alignment: Alignment.center, transform: Matrix4.rotationY(math.pi), child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _cguAccepted
|
||||
? () {
|
||||
_registrationData.updateMotivation(_motivationController.text);
|
||||
_registrationData.acceptCGU();
|
||||
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/parent-register/step5',
|
||||
arguments: _registrationData
|
||||
);
|
||||
}
|
||||
: null,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return PresentationFormScreen(
|
||||
stepText: 'Étape 4/5',
|
||||
title: 'Motivation de votre demande',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
textFieldHint: 'Écrivez ici pour motiver votre demande...',
|
||||
initialText: initialText,
|
||||
initialCguAccepted: initialCgu,
|
||||
previousRoute: '/parent-register-step3',
|
||||
onSubmit: (text, cguAccepted) {
|
||||
registrationData.updateMotivation(text);
|
||||
registrationData.acceptCGU(cguAccepted);
|
||||
// Les infos financières peuvent être gérées ailleurs si nécessaire
|
||||
context.go('/parent-register-step5');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../models/user_registration_data.dart'; // Utilisation du vrai modèle
|
||||
import '../../widgets/image_button.dart'; // Import du ImageButton
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../widgets/custom_decorated_text_field.dart'; // Import du CustomDecoratedTextField
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
// Nouvelle méthode helper pour afficher un champ de type "lecture seule" stylisé
|
||||
Widget _buildDisplayFieldValue(BuildContext context, String label, String value, {bool multiLine = false, double fieldHeight = 50.0, double labelFontSize = 18.0}) {
|
||||
const FontWeight labelFontWeight = FontWeight.w600;
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../config/display_config.dart';
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../widgets/child_card_widget.dart';
|
||||
import '../../widgets/presentation_form_screen.dart';
|
||||
|
||||
// Ne pas afficher le label si labelFontSize est 0 ou si label est vide
|
||||
bool showLabel = label.isNotEmpty && labelFontSize > 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showLabel)
|
||||
Text(label, style: GoogleFonts.merienda(fontSize: labelFontSize, fontWeight: labelFontWeight)),
|
||||
if (showLabel)
|
||||
const SizedBox(height: 4),
|
||||
// Utiliser Expanded si multiLine et pas de hauteur fixe, sinon Container
|
||||
multiLine && fieldHeight == null
|
||||
? Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView( // Pour le défilement si le texte dépasse
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0), // Garder une taille de texte par défaut si label caché
|
||||
maxLines: null, // Permettre un nombre illimité de lignes
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: multiLine ? null : fieldHeight,
|
||||
constraints: multiLine ? BoxConstraints(minHeight: fieldHeight) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/input_field_bg.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: labelFontSize > 0 ? labelFontSize : 18.0),
|
||||
maxLines: multiLine ? null : 1,
|
||||
overflow: multiLine ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
final UserRegistrationData registrationData;
|
||||
|
||||
const ParentRegisterStep5Screen({super.key, required this.registrationData});
|
||||
|
||||
// Méthode pour construire la carte Parent 1
|
||||
Widget _buildParent1Card(BuildContext context, ParentData data) {
|
||||
const double verticalSpacing = 28.0; // Espacement vertical augmenté
|
||||
const double labelFontSize = 22.0; // Taille de label augmentée
|
||||
|
||||
List<Widget> details = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nom:", data.lastName, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Prénom:", data.firstName, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Téléphone:", data.phone, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Email:", data.email, multiLine: true, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
_buildDisplayFieldValue(context, "Adresse:", "${data.address}\n${data.postalCode} ${data.city}".trim(), multiLine: true, fieldHeight: 80, labelFontSize: labelFontSize),
|
||||
];
|
||||
return _SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.peach.path,
|
||||
title: 'Parent Principal',
|
||||
content: details,
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step1', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour construire la carte Parent 2
|
||||
Widget _buildParent2Card(BuildContext context, ParentData data) {
|
||||
const double verticalSpacing = 28.0;
|
||||
const double labelFontSize = 22.0;
|
||||
List<Widget> details = [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Nom:", data.lastName, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Prénom:", data.firstName, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Téléphone:", data.phone, labelFontSize: labelFontSize)),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: _buildDisplayFieldValue(context, "Email:", data.email, multiLine: true, labelFontSize: labelFontSize)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
_buildDisplayFieldValue(context, "Adresse:", "${data.address}\n${data.postalCode} ${data.city}".trim(), multiLine: true, fieldHeight: 80, labelFontSize: labelFontSize),
|
||||
];
|
||||
return _SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.blue.path,
|
||||
title: 'Deuxième Parent',
|
||||
content: details,
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step2', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour construire les cartes Enfants
|
||||
List<Widget> _buildChildrenCards(BuildContext context, List<ChildData> children) {
|
||||
return children.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
ChildData child = entry.value;
|
||||
|
||||
CardColorHorizontal cardColorHorizontal = CardColorHorizontal.values.firstWhere(
|
||||
(e) => e.name == child.cardColor.name,
|
||||
orElse: () => CardColorHorizontal.lavender,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(cardColorHorizontal.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre centré dans la carte
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enfant ${index + 1}' + (child.isUnbornChild ? ' (à naître)' : ''),
|
||||
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
'/parent-register/step3',
|
||||
arguments: registrationData,
|
||||
);
|
||||
},
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// IMAGE SANS CADRE BLANC, PREND LA HAUTEUR
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: (child.imageFile != null)
|
||||
? (kIsWeb
|
||||
? Image.network(child.imageFile!.path, fit: BoxFit.cover)
|
||||
: Image.file(child.imageFile!, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
// INFOS À DROITE (2/3)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildDisplayFieldValue(context, 'Prénom :', child.firstName, labelFontSize: 22.0),
|
||||
const SizedBox(height: 12),
|
||||
_buildDisplayFieldValue(context, 'Nom :', child.lastName, labelFontSize: 22.0),
|
||||
const SizedBox(height: 12),
|
||||
_buildDisplayFieldValue(context, child.isUnbornChild ? 'Date de naissance :' : 'Date de naissance :', child.dob, labelFontSize: 22.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// Ligne des consentements
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: child.photoConsent,
|
||||
onChanged: null,
|
||||
),
|
||||
Text('Consentement photo', style: GoogleFonts.merienda(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: child.multipleBirth,
|
||||
onChanged: null,
|
||||
),
|
||||
Text('Naissance multiple', style: GoogleFonts.merienda(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Méthode pour construire la carte Motivation
|
||||
Widget _buildMotivationCard(BuildContext context, String motivation) {
|
||||
return _SummaryCard(
|
||||
backgroundImagePath: CardColorHorizontal.green.path,
|
||||
title: 'Votre Motivation',
|
||||
content: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: TextEditingController(text: motivation),
|
||||
hintText: 'Aucune motivation renseignée.',
|
||||
fieldHeight: 200,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
readOnly: true,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
onEdit: () => Navigator.of(context).pushNamed('/parent-register/step4', arguments: registrationData),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour afficher une ligne de détail (police et agencement amélioré)
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"$label: ",
|
||||
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: 18),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
class ParentRegisterStep5Screen extends StatefulWidget {
|
||||
const ParentRegisterStep5Screen({super.key});
|
||||
|
||||
@override
|
||||
_ParentRegisterStep5ScreenState createState() => _ParentRegisterStep5ScreenState();
|
||||
}
|
||||
|
||||
class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<UserRegistrationData>(context);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = screenSize.width / 2.0; // Largeur de la carte (50% de l'écran)
|
||||
final double imageAspectRatio = 2.0; // Ratio corrigé (1024/512 = 2.0)
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
final config = DisplayConfig.fromContext(context, mode: DisplayMode.readonly);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
@@ -322,9 +36,11 @@ class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0), // Padding horizontal supprimé ici
|
||||
child: Padding( // Ajout du Padding horizontal externe
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width / 4.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: config.isMobile ? 0 : screenSize.width / 4.0
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -334,46 +50,202 @@ class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
Text('Récapitulatif de votre demande', style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_buildParent1Card(context, registrationData.parent1),
|
||||
// Carte Parent 1
|
||||
_buildParent1(context, registrationData),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Carte Parent 2 (si présent)
|
||||
if (registrationData.parent2 != null) ...[
|
||||
_buildParent2Card(context, registrationData.parent2!),
|
||||
_buildParent2(context, registrationData),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
..._buildChildrenCards(context, registrationData.children),
|
||||
_buildMotivationCard(context, registrationData.motivationText),
|
||||
const SizedBox(height: 40),
|
||||
ImageButton(
|
||||
bg: 'assets/images/btn_green.png',
|
||||
text: 'Soumettre ma demande',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
width: 350,
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
onPressed: () {
|
||||
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
|
||||
// Cartes Enfants
|
||||
...registrationData.children.asMap().entries.map((entry) =>
|
||||
Column(
|
||||
children: [
|
||||
_buildChildCard(context, entry.value, entry.key),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
)
|
||||
),
|
||||
|
||||
// Carte Motivation
|
||||
_buildMotivation(context, registrationData),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Boutons Mobile (Retour + Soumettre) ou Bouton Soumettre Desktop
|
||||
if (config.isMobile)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/parent-register-step4');
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Soumettre',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: () {
|
||||
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
ImageButton(
|
||||
bg: 'assets/images/bg_green.png',
|
||||
text: 'Soumettre ma demande',
|
||||
textColor: const Color(0xFF2D6A4F),
|
||||
width: 350,
|
||||
height: 50,
|
||||
fontSize: 18,
|
||||
onPressed: () {
|
||||
print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}");
|
||||
_showConfirmationModal(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform.flip(flipX: true, child: Image.asset('assets/images/chevron_right.png', height: 40)),
|
||||
onPressed: () => Navigator.pop(context), // Retour à l'étape 4
|
||||
tooltip: 'Retour',
|
||||
// Chevrons desktop uniquement
|
||||
if (!config.isMobile)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/parent-register-step4');
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParent1(BuildContext context, UserRegistrationData data) {
|
||||
return PersonalInfoFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Informations du Parent Principal',
|
||||
cardColor: CardColorHorizontal.peach,
|
||||
initialData: PersonalInfoData(
|
||||
firstName: data.parent1.firstName,
|
||||
lastName: data.parent1.lastName,
|
||||
phone: data.parent1.phone,
|
||||
email: data.parent1.email,
|
||||
address: data.parent1.address,
|
||||
postalCode: data.parent1.postalCode,
|
||||
city: data.parent1.city,
|
||||
),
|
||||
onSubmit: (d, {hasSecondPerson, sameAddress}) {},
|
||||
previousRoute: '',
|
||||
onEdit: () => context.go('/parent-register-step1'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParent2(BuildContext context, UserRegistrationData data) {
|
||||
if (data.parent2 == null) return const SizedBox();
|
||||
return PersonalInfoFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Informations du Deuxième Parent',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: PersonalInfoData(
|
||||
firstName: data.parent2!.firstName,
|
||||
lastName: data.parent2!.lastName,
|
||||
phone: data.parent2!.phone,
|
||||
email: data.parent2!.email,
|
||||
address: data.parent2!.address,
|
||||
postalCode: data.parent2!.postalCode,
|
||||
city: data.parent2!.city,
|
||||
),
|
||||
onSubmit: (d, {hasSecondPerson, sameAddress}) {},
|
||||
previousRoute: '',
|
||||
onEdit: () => context.go('/parent-register-step2'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChildCard(BuildContext context, ChildData child, int index) {
|
||||
// Note: Le titre est maintenant intégré dans la carte ChildCardWidget en mode readonly
|
||||
return Column(
|
||||
children: [
|
||||
ChildCardWidget(
|
||||
key: ValueKey('child_readonly_$index'),
|
||||
childData: child,
|
||||
childIndex: index,
|
||||
mode: DisplayMode.readonly,
|
||||
onPickImage: () {},
|
||||
onDateSelect: () {},
|
||||
onFirstNameChanged: (v) {},
|
||||
onLastNameChanged: (v) {},
|
||||
onTogglePhotoConsent: (v) {},
|
||||
onToggleMultipleBirth: (v) {},
|
||||
onToggleIsUnborn: (v) {},
|
||||
onRemove: () {},
|
||||
canBeRemoved: false,
|
||||
onEdit: () => context.go('/parent-register-step3', extra: {'childIndex': index}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMotivation(BuildContext context, UserRegistrationData data) {
|
||||
return PresentationFormScreen(
|
||||
mode: DisplayMode.readonly,
|
||||
embedContentOnly: true,
|
||||
stepText: '',
|
||||
title: 'Votre Motivation',
|
||||
cardColor: CardColorHorizontal.green, // Changé de pink à green
|
||||
textFieldHint: '',
|
||||
initialText: data.motivationText,
|
||||
initialCguAccepted: true, // Toujours true ici car déjà passé
|
||||
previousRoute: '',
|
||||
onSubmit: (t, c) {},
|
||||
onEdit: () => context.go('/parent-register-step4'),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@@ -392,9 +264,8 @@ class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Ferme la modale
|
||||
// TODO: Naviguer vers l'écran de connexion ou tableau de bord
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/login', (Route<dynamic> route) => false);
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -403,62 +274,4 @@ class ParentRegisterStep5Screen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Widget générique _SummaryCard (ajusté)
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String backgroundImagePath;
|
||||
final String title;
|
||||
final List<Widget> content;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const _SummaryCard({
|
||||
super.key,
|
||||
required this.backgroundImagePath,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(backgroundImagePath),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:math' as math; // Pour la rotation du chevron
|
||||
import '../../widgets/hover_relief_widget.dart'; // Import du widget générique
|
||||
import '../../models/card_assets.dart'; // Import des enums de cartes
|
||||
import 'dart:math' as math;
|
||||
import '../../widgets/choice_card_widget.dart';
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class RegisterChoiceScreen extends StatelessWidget {
|
||||
const RegisterChoiceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Fond papier
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
final screenSize = Size(width, height);
|
||||
final isMobile = width < 900;
|
||||
|
||||
// Bouton Retour (chevron gauche)
|
||||
Positioned(
|
||||
top: 40,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond papier
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton Retour (chevron gauche) - Desktop uniquement
|
||||
if (!isMobile)
|
||||
Positioned(
|
||||
top: 40,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform.flip(
|
||||
flipX: true,
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
isMobile
|
||||
? _buildMobileLayout(context, screenSize)
|
||||
: _buildDesktopLayout(context, screenSize),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopLayout(BuildContext context, Size screenSize) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Row(
|
||||
children: [
|
||||
// Partie Gauche: Texte d'instruction centré
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Veuillez choisir votre\ntype de compte :',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
// Espace entre les deux parties
|
||||
SizedBox(width: screenSize.width * 0.05),
|
||||
|
||||
// Contenu principal en Row (Gauche / Droite)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Row(
|
||||
children: [
|
||||
// Partie Gauche: Texte d'instruction centré
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Veuillez choisir votre\ntype de compte :',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Partie Droite: Carte rose avec les boutons
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: screenSize.height * 0.78,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: ChoiceCardWidget(
|
||||
isMobile: false,
|
||||
onParentSelected: () => context.go('/parent-register-step1'),
|
||||
onAmSelected: () => context.go('/am-register-step1'),
|
||||
),
|
||||
),
|
||||
// Espace entre les deux parties
|
||||
SizedBox(width: screenSize.width * 0.05),
|
||||
|
||||
// Partie Droite: Carte rose avec les boutons
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: screenSize.height * 0.78, // Augmenté pour éviter l'overflow
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorVertical.pink.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Bouton "Parents" avec HoverReliefWidget appliqué uniquement à l'image
|
||||
_buildChoiceButton(
|
||||
context: context,
|
||||
iconPath: 'assets/images/icon_parents.png',
|
||||
label: 'Parents',
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/parent-register/step1');
|
||||
},
|
||||
),
|
||||
// Bouton "Assistante Maternelle" avec HoverReliefWidget appliqué uniquement à l'image
|
||||
_buildChoiceButton(
|
||||
context: context,
|
||||
iconPath: 'assets/images/icon_assmat.png',
|
||||
label: 'Assistante Maternelle',
|
||||
onPressed: () {
|
||||
// TODO: Naviguer vers l'écran d'inscription assmat
|
||||
print('Choix: Assistante Maternelle');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Nouvelle méthode helper pour construire les boutons de choix
|
||||
Widget _buildChoiceButton({
|
||||
required BuildContext context,
|
||||
required String iconPath,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
// TODO: Déterminer la couleur de base de card_rose.png et ajuster ces couleurs d'ombre
|
||||
final Color baseRoseColor = Colors.pink.shade300; // Placeholder
|
||||
final Color initialShadow = baseRoseColor.withAlpha(90); // Rose plus foncé et transparent pour l'ombre initiale
|
||||
final Color hoverShadow = baseRoseColor.withAlpha(130); // Rose encore plus foncé pour l'ombre au survol
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: onPressed,
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
initialShadowColor: initialShadow, // Ombre rose initiale
|
||||
hoverShadowColor: hoverShadow, // Ombre rose au survol
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Image.asset(iconPath, height: 140),
|
||||
Widget _buildMobileLayout(BuildContext context, Size screenSize) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Veuillez choisir votre\ntype de compte :',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
height: 1.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
// Carte rose verticale
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: ChoiceCardWidget(
|
||||
isMobile: true,
|
||||
onParentSelected: () => context.go('/parent-register-step1'),
|
||||
onAmSelected: () => context.go('/am-register-step1'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
// Bouton Précédent
|
||||
HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black.withOpacity(0.85),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- La classe HoverChoiceButton peut maintenant être supprimée si elle n'est plus utilisée ailleurs ---
|
||||
// class HoverChoiceButton extends StatefulWidget { ... }
|
||||
// class _HoverChoiceButtonState extends State<HoverChoiceButton> { ... }
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UnknownScreen extends StatelessWidget {
|
||||
const UnknownScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Page Introuvable')),
|
||||
body: const Center(
|
||||
child: Text('Désolé, cette page n\'existe pas.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ class ApiConfig {
|
||||
// Auth endpoints
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String registerParent = '/auth/register/parent';
|
||||
static const String registerAM = '/auth/register/am';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String authMe = '/auth/me';
|
||||
static const String changePasswordRequired = '/auth/change-password-required';
|
||||
@@ -13,6 +15,16 @@ class ApiConfig {
|
||||
static const String users = '/users';
|
||||
static const String userProfile = '/users/profile';
|
||||
static const String userChildren = '/users/children';
|
||||
static const String gestionnaires = '/gestionnaires';
|
||||
static const String parents = '/parents';
|
||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||
|
||||
// Configuration (admin)
|
||||
static const String configuration = '/configuration';
|
||||
static const String configurationSetupStatus = '/configuration/setup/status';
|
||||
static const String configurationSetupComplete = '/configuration/setup/complete';
|
||||
static const String configurationTestSmtp = '/configuration/test-smtp';
|
||||
static const String configurationBulk = '/configuration/bulk';
|
||||
|
||||
// Dashboard endpoints
|
||||
static const String dashboard = '/dashboard';
|
||||
|
||||
@@ -23,13 +23,15 @@ class AuthService {
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
// Stocker les tokens
|
||||
await TokenService.saveToken(data['accessToken']);
|
||||
await TokenService.saveRefreshToken(data['refreshToken']);
|
||||
|
||||
// Récupérer le profil utilisateur pour avoir toutes les infos
|
||||
final user = await _fetchUserProfile(data['accessToken']);
|
||||
// API renvoie access_token / refresh_token (snake_case)
|
||||
final accessToken = data['access_token'] as String? ?? data['accessToken'] as String?;
|
||||
final refreshToken = data['refresh_token'] as String? ?? data['refreshToken'] as String?;
|
||||
if (accessToken == null) throw Exception('Token absent dans la réponse serveur');
|
||||
|
||||
await TokenService.saveToken(accessToken);
|
||||
await TokenService.saveRefreshToken(refreshToken ?? '');
|
||||
|
||||
final user = await _fetchUserProfile(accessToken);
|
||||
|
||||
// Stocker l'utilisateur en cache
|
||||
await _saveCurrentUser(user);
|
||||
@@ -41,7 +43,8 @@ class AuthService {
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Erreur réseau: impossible de se connecter au serveur');
|
||||
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
|
||||
throw Exception('Erreur réseau: impossible de se connecter au serveur ($e)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,14 +57,22 @@ class AuthService {
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final raw = jsonDecode(response.body);
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
throw Exception('Profil invalide: réponse serveur inattendue');
|
||||
}
|
||||
// Accepter réponse directe ou wrapper { data: {...} }
|
||||
final data = raw.containsKey('data') && raw['data'] is Map<String, dynamic>
|
||||
? raw['data'] as Map<String, dynamic>
|
||||
: raw;
|
||||
return AppUser.fromJson(data);
|
||||
} else {
|
||||
throw Exception('Erreur lors de la récupération du profil');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Erreur réseau: impossible de récupérer le profil');
|
||||
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
|
||||
throw Exception('Erreur réseau: impossible de récupérer le profil ($e)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +91,9 @@ class AuthService {
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'),
|
||||
headers: ApiConfig.authHeaders(token),
|
||||
body: jsonEncode({
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
'mot_de_passe_actuel': currentPassword,
|
||||
'nouveau_mot_de_passe': newPassword,
|
||||
'confirmation_mot_de_passe': newPassword,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -95,7 +107,8 @@ class AuthService {
|
||||
await _saveCurrentUser(user);
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('Erreur réseau: impossible de changer le mot de passe');
|
||||
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
|
||||
throw Exception('Erreur réseau: impossible de changer le mot de passe ($e)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'api/api_config.dart';
|
||||
import 'api/tokenService.dart';
|
||||
|
||||
/// Réponse GET /configuration (liste complète)
|
||||
class ConfigItem {
|
||||
final String cle;
|
||||
final String? valeur;
|
||||
final String type;
|
||||
final String? categorie;
|
||||
final String? description;
|
||||
|
||||
ConfigItem({
|
||||
required this.cle,
|
||||
this.valeur,
|
||||
required this.type,
|
||||
this.categorie,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory ConfigItem.fromJson(Map<String, dynamic> json) {
|
||||
return ConfigItem(
|
||||
cle: json['cle'] as String,
|
||||
valeur: json['valeur'] as String?,
|
||||
type: json['type'] as String? ?? 'string',
|
||||
categorie: json['categorie'] as String?,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse GET /configuration/:category (objet clé -> { value, type, description })
|
||||
class ConfigValueItem {
|
||||
final dynamic value;
|
||||
final String type;
|
||||
final String? description;
|
||||
|
||||
ConfigValueItem({required this.value, required this.type, this.description});
|
||||
|
||||
factory ConfigValueItem.fromJson(Map<String, dynamic> json) {
|
||||
return ConfigValueItem(
|
||||
value: json['value'],
|
||||
type: json['type'] as String? ?? 'string',
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigurationService {
|
||||
static Future<Map<String, String>> _headers() async {
|
||||
final token = await TokenService.getToken();
|
||||
return token != null
|
||||
? ApiConfig.authHeaders(token)
|
||||
: Map<String, String>.from(ApiConfig.headers);
|
||||
}
|
||||
|
||||
static String? _toStr(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v;
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
/// GET /api/v1/configuration/setup/status
|
||||
static Future<bool> getSetupStatus() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupStatus}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200) return true;
|
||||
final data = jsonDecode(response.body);
|
||||
final val = data['data']?['setupCompleted'];
|
||||
if (val is bool) return val;
|
||||
if (val is String) return val.toLowerCase() == 'true' || val == '1';
|
||||
if (val is int) return val == 1;
|
||||
return true; // Par défaut on considère configuré pour ne pas bloquer
|
||||
}
|
||||
|
||||
/// GET /api/v1/configuration (toutes les configs)
|
||||
static Future<List<ConfigItem>> getAll() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
|
||||
}
|
||||
final data = jsonDecode(response.body);
|
||||
final list = data['data'] as List<dynamic>? ?? [];
|
||||
return list.map((e) => ConfigItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// GET /api/v1/configuration/:category
|
||||
static Future<Map<String, ConfigValueItem>> getByCategory(String category) async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}/$category'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
|
||||
}
|
||||
final data = jsonDecode(response.body);
|
||||
final map = data['data'] as Map<String, dynamic>? ?? {};
|
||||
return map.map((k, v) => MapEntry(k, ConfigValueItem.fromJson(v as Map<String, dynamic>)));
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/configuration/bulk
|
||||
static Future<void> updateBulk(Map<String, dynamic> body) async {
|
||||
final response = await http.patch(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationBulk}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
|
||||
throw Exception(msg ?? 'Erreur lors de la sauvegarde');
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/configuration/test-smtp
|
||||
static Future<String> testSmtp(String testEmail) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationTestSmtp}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({'testEmail': testEmail}),
|
||||
);
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) {
|
||||
return _toStr(data?['message']) ?? 'Test SMTP réussi.';
|
||||
}
|
||||
final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null;
|
||||
throw Exception(msg ?? 'Échec du test SMTP');
|
||||
}
|
||||
|
||||
/// POST /api/v1/configuration/setup/complete (après première config)
|
||||
static Future<void> completeSetup() async {
|
||||
final response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
|
||||
throw Exception(msg ?? 'Erreur finalisation configuration');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
|
||||
class UserService {
|
||||
static Future<Map<String, String>> _headers() async {
|
||||
final token = await TokenService.getToken();
|
||||
return token != null
|
||||
? ApiConfig.authHeaders(token)
|
||||
: Map<String, String>.from(ApiConfig.headers);
|
||||
}
|
||||
|
||||
static String? _toStr(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v;
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
// Récupérer la liste des gestionnaires (endpoint dédié)
|
||||
static Future<List<AppUser>> getGestionnaires() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
||||
}
|
||||
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data.map((e) => AppUser.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
// Récupérer la liste des parents
|
||||
static Future<List<ParentModel>> getParents() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parents');
|
||||
}
|
||||
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data.map((e) => ParentModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
// Récupérer la liste des assistantes maternelles
|
||||
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
|
||||
}
|
||||
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
||||
// Pour l'instant on va utiliser /users et filtrer côté client si on est super admin
|
||||
static Future<List<AppUser>> getAdministrateurs() async {
|
||||
// TODO: Endpoint dédié ou filtrage
|
||||
// En attendant, on retourne une liste vide ou on tente /users
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data
|
||||
.map((e) => AppUser.fromJson(e))
|
||||
.where((u) => u.role == 'administrateur' || u.role == 'super_admin')
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur chargement admins: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import 'dart:math';
|
||||
class DataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
// Méthodes publiques pour la génération de nombres aléatoires
|
||||
static int randomInt(int max) => _random.nextInt(max);
|
||||
static int randomIntInRange(int min, int max) => min + _random.nextInt(max - min);
|
||||
static bool randomBool() => _random.nextBool();
|
||||
|
||||
static final List<String> _firstNames = [
|
||||
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Félix', 'Gabrielle', 'Hugo', 'Inès', 'Jules',
|
||||
'Léa', 'Manon', 'Nathan', 'Oscar', 'Pauline', 'Quentin', 'Raphaël', 'Sophie', 'Théo', 'Victoire'
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# Infrastructure générique pour les formulaires
|
||||
|
||||
## 📋 Vue d'ensemble
|
||||
|
||||
Cette infrastructure permet de créer des formulaires qui s'adaptent automatiquement :
|
||||
- **Mode éditable** (inscription) vs **lecture seule** (récapitulatif)
|
||||
- **Layout mobile** (vertical, < 600px) vs **desktop** (horizontal, ≥ 600px)
|
||||
- **Mobile reste toujours vertical**, même en rotation paysage
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### 1. `display_config.dart` - Configuration centrale
|
||||
|
||||
```dart
|
||||
// Mode d'affichage
|
||||
enum DisplayMode {
|
||||
editable, // Formulaire éditable
|
||||
readonly, // Récapitulatif
|
||||
}
|
||||
|
||||
// Type de layout
|
||||
enum LayoutType {
|
||||
mobile, // < 600px, toujours vertical
|
||||
desktop, // ≥ 600px, horizontal
|
||||
}
|
||||
|
||||
// Configuration complète
|
||||
DisplayConfig config = DisplayConfig.fromContext(
|
||||
context,
|
||||
mode: DisplayMode.editable,
|
||||
);
|
||||
```
|
||||
|
||||
### 2. `form_field_wrapper.dart` - Champs génériques
|
||||
|
||||
#### FormFieldWrapper
|
||||
Widget pour afficher un champ unique qui s'adapte automatiquement.
|
||||
|
||||
**Mode éditable :**
|
||||
```dart
|
||||
FormFieldWrapper(
|
||||
config: config,
|
||||
label: 'Prénom',
|
||||
value: '',
|
||||
controller: firstNameController,
|
||||
onChanged: (value) => {},
|
||||
hint: 'Entrez votre prénom',
|
||||
)
|
||||
```
|
||||
|
||||
**Mode readonly :**
|
||||
```dart
|
||||
FormFieldWrapper(
|
||||
config: config,
|
||||
label: 'Prénom',
|
||||
value: 'Jean',
|
||||
)
|
||||
```
|
||||
|
||||
#### FormFieldRow
|
||||
Widget pour afficher plusieurs champs sur une ligne (desktop) ou en colonne (mobile).
|
||||
|
||||
```dart
|
||||
FormFieldRow(
|
||||
config: config,
|
||||
fields: [
|
||||
FormFieldWrapper(...),
|
||||
FormFieldWrapper(...),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 3. `base_form_screen.dart` - Structure de page générique
|
||||
|
||||
Encapsule toute la structure d'une page de formulaire :
|
||||
- En-tête (étape + titre)
|
||||
- Carte avec fond adapté (horizontal/vertical)
|
||||
- Boutons de navigation
|
||||
- Gestion automatique du layout
|
||||
|
||||
```dart
|
||||
BaseFormScreen(
|
||||
config: DisplayConfig.fromContext(
|
||||
context,
|
||||
mode: DisplayMode.editable,
|
||||
),
|
||||
stepText: 'Étape 1/4',
|
||||
title: 'Informations personnelles',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
previousRoute: '/previous',
|
||||
onSubmit: () => _handleSubmit(),
|
||||
content: Column(
|
||||
children: [
|
||||
FormFieldRow(
|
||||
config: config,
|
||||
fields: [
|
||||
FormFieldWrapper(...),
|
||||
FormFieldWrapper(...),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
## 📱 Comportement responsive
|
||||
|
||||
### Breakpoint : 600px
|
||||
|
||||
| Largeur écran | LayoutType | Orientation carte | Disposition champs |
|
||||
|--------------|------------|-------------------|-------------------|
|
||||
| < 600px | mobile | Verticale | Colonne |
|
||||
| ≥ 600px | desktop | Horizontale | Ligne |
|
||||
|
||||
### Règle importante
|
||||
**Sur mobile, le layout reste TOUJOURS vertical**, même si l'utilisateur tourne son téléphone en mode paysage.
|
||||
|
||||
## 🎨 Utilisation dans un widget de formulaire
|
||||
|
||||
### Exemple : PersonalInfoFormScreen
|
||||
|
||||
```dart
|
||||
class PersonalInfoFormScreen extends StatefulWidget {
|
||||
final DisplayMode mode;
|
||||
final PersonalInfoData? initialData;
|
||||
final Function(PersonalInfoData) onSubmit;
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
late TextEditingController _firstNameController;
|
||||
late TextEditingController _lastNameController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = DisplayConfig.fromContext(
|
||||
context,
|
||||
mode: widget.mode,
|
||||
);
|
||||
|
||||
return BaseFormScreen(
|
||||
config: config,
|
||||
stepText: 'Étape 1/4',
|
||||
title: 'Informations personnelles',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
previousRoute: '/previous',
|
||||
onSubmit: _handleSubmit,
|
||||
content: Column(
|
||||
children: [
|
||||
FormFieldRow(
|
||||
config: config,
|
||||
fields: [
|
||||
FormFieldWrapper(
|
||||
config: config,
|
||||
label: 'Prénom',
|
||||
value: _firstNameController.text,
|
||||
controller: config.isEditable ? _firstNameController : null,
|
||||
onChanged: config.isEditable ? (v) => setState(() {}) : null,
|
||||
),
|
||||
FormFieldWrapper(
|
||||
config: config,
|
||||
label: 'Nom',
|
||||
value: _lastNameController.text,
|
||||
controller: config.isEditable ? _lastNameController : null,
|
||||
onChanged: config.isEditable ? (v) => setState(() {}) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
final data = PersonalInfoData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
);
|
||||
widget.onSubmit(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Avantages
|
||||
|
||||
1. **Code unique** : Un seul widget pour éditable + readonly + mobile + desktop
|
||||
2. **Cohérence** : Tous les formulaires se comportent de la même façon
|
||||
3. **Maintenance** : Modification centralisée de l'UI
|
||||
4. **Performance** : Pas de rebuild inutile, layout déterminé au build
|
||||
5. **Simplicité** : API claire et prévisible
|
||||
|
||||
## 🔧 Utilitaires disponibles
|
||||
|
||||
```dart
|
||||
// Détecter le type de layout
|
||||
bool isMobile = LayoutHelper.isMobile(context);
|
||||
bool isDesktop = LayoutHelper.isDesktop(context);
|
||||
|
||||
// Espacement adaptatif
|
||||
double spacing = LayoutHelper.getSpacing(
|
||||
context,
|
||||
mobileSpacing: 12.0,
|
||||
desktopSpacing: 20.0,
|
||||
);
|
||||
|
||||
// Largeur max adaptative
|
||||
double maxWidth = LayoutHelper.getMaxWidth(context);
|
||||
```
|
||||
|
||||
## 🚀 Migration des widgets existants
|
||||
|
||||
Pour migrer un widget existant vers cette infrastructure :
|
||||
|
||||
1. Ajouter paramètre `DisplayMode mode`
|
||||
2. Créer `DisplayConfig.fromContext(context, mode: widget.mode)`
|
||||
3. Remplacer la structure Scaffold par `BaseFormScreen`
|
||||
4. Remplacer les champs par `FormFieldWrapper`
|
||||
5. Grouper les champs avec `FormFieldRow`
|
||||
6. Tester en mode editable + readonly + mobile + desktop
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
|
||||
class AdminManagementWidget extends StatefulWidget {
|
||||
const AdminManagementWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
||||
}
|
||||
|
||||
class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AppUser> _admins = [];
|
||||
List<AppUser> _filteredAdmins = [];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAdmins();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAdmins() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await UserService.getAdministrateurs();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_admins = list;
|
||||
_filteredAdmins = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredAdmins = _admins.where((u) {
|
||||
final name = u.fullName.toLowerCase();
|
||||
final email = u.email.toLowerCase();
|
||||
return name.contains(query) || email.contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Rechercher un administrateur...",
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Créer admin
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Créer un admin"),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_error != null)
|
||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||
else if (_filteredAdmins.isEmpty)
|
||||
const Center(child: Text("Aucun administrateur trouvé."))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredAdmins.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = _filteredAdmins[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(user.fullName.isNotEmpty
|
||||
? user.fullName[0].toUpperCase()
|
||||
: 'A'),
|
||||
),
|
||||
title: Text(user.fullName.isNotEmpty
|
||||
? user.fullName
|
||||
: 'Sans nom'),
|
||||
subtitle: Text(user.email),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
|
||||
class AssistanteMaternelleManagementWidget extends StatelessWidget {
|
||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||
const AssistanteMaternelleManagementWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assistantes = [
|
||||
{
|
||||
"nom": "Marie Dupont",
|
||||
"numeroAgrement": "AG123456",
|
||||
"zone": "Paris 14",
|
||||
"capacite": 3,
|
||||
},
|
||||
{
|
||||
"nom": "Claire Martin",
|
||||
"numeroAgrement": "AG654321",
|
||||
"zone": "Lyon 7",
|
||||
"capacite": 2,
|
||||
},
|
||||
];
|
||||
State<AssistanteMaternelleManagementWidget> createState() =>
|
||||
_AssistanteMaternelleManagementWidgetState();
|
||||
}
|
||||
|
||||
class _AssistanteMaternelleManagementWidgetState
|
||||
extends State<AssistanteMaternelleManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AssistanteMaternelleModel> _assistantes = [];
|
||||
List<AssistanteMaternelleModel> _filteredAssistantes = [];
|
||||
|
||||
final TextEditingController _zoneController = TextEditingController();
|
||||
final TextEditingController _capacityController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAssistantes();
|
||||
_zoneController.addListener(_filter);
|
||||
_capacityController.addListener(_filter);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zoneController.dispose();
|
||||
_capacityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAssistantes() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await UserService.getAssistantesMaternelles();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_assistantes = list;
|
||||
_filter();
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _filter() {
|
||||
final zoneQuery = _zoneController.text.toLowerCase();
|
||||
final capacityQuery = int.tryParse(_capacityController.text);
|
||||
|
||||
setState(() {
|
||||
_filteredAssistantes = _assistantes.where((am) {
|
||||
final matchesZone = zoneQuery.isEmpty ||
|
||||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
|
||||
final matchesCapacity = capacityQuery == null ||
|
||||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
|
||||
return matchesZone && matchesCapacity;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔎 Zone de filtre
|
||||
_buildFilterSection(),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔎 Zone de filtre
|
||||
_buildFilterSection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 📋 Liste des assistantes
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: assistantes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final assistante = assistantes[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.face),
|
||||
title: Text(assistante['nom'].toString()),
|
||||
subtitle: Text(
|
||||
"N° Agrément : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | Capacité : ${assistante['capacite']}"),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
// TODO: Ajouter modification
|
||||
},
|
||||
// 📋 Liste des assistantes
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_error != null)
|
||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||
else if (_filteredAssistantes.isEmpty)
|
||||
const Center(child: Text("Aucune assistante maternelle trouvée."))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredAssistantes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final assistante = _filteredAssistantes[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundImage: assistante.user.photoUrl != null
|
||||
? NetworkImage(assistante.user.photoUrl!)
|
||||
: null,
|
||||
child: assistante.user.photoUrl == null
|
||||
? const Icon(Icons.face)
|
||||
: null,
|
||||
),
|
||||
title: Text(assistante.user.fullName.isNotEmpty
|
||||
? assistante.user.fullName
|
||||
: 'Sans nom'),
|
||||
subtitle: Text(
|
||||
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
// TODO: Ajouter modification
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
// TODO: Ajouter suppression
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
// TODO: Ajouter suppression
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,26 +148,23 @@ class AssistanteMaternelleManagementWidget extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: _zoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Zone géographique",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.location_on),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// TODO: Ajouter logique de filtrage par zone
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: _capacityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Capacité minimum",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
// TODO: Ajouter logique de filtrage par capacité
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
|
||||
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
|
||||
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onTabChange;
|
||||
final bool setupCompleted;
|
||||
|
||||
const DashboardAppBarAdmin({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key);
|
||||
const DashboardAppBarAdmin({
|
||||
Key? key,
|
||||
required this.selectedIndex,
|
||||
required this.onTabChange,
|
||||
this.setupCompleted = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 768;
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
title: Row(
|
||||
children: [
|
||||
SizedBox(width: MediaQuery.of(context).size.width * 0.19),
|
||||
const Text(
|
||||
"P'tit Pas",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF9CC5C0),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
const SizedBox(width: 24),
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
height: 40,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
|
||||
const SizedBox(width: 24),
|
||||
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
|
||||
|
||||
// Navigation principale
|
||||
_buildNavItem(context, 'Gestionnaires', 0),
|
||||
const SizedBox(width: 24),
|
||||
_buildNavItem(context, 'Parents', 1),
|
||||
const SizedBox(width: 24),
|
||||
_buildNavItem(context, 'Assistantes maternelles', 2),
|
||||
const SizedBox(width: 24),
|
||||
_buildNavItem(context, 'Administrateurs', 3),
|
||||
],
|
||||
),
|
||||
actions: isMobile
|
||||
? [_buildMobileMenu(context)]
|
||||
: [
|
||||
// Nom de l'utilisateur
|
||||
actions: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(
|
||||
@@ -55,8 +59,6 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton déconnexion
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: TextButton(
|
||||
@@ -72,51 +74,33 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
||||
child: const Text('Se déconnecter'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(BuildContext context, String title, int index) {
|
||||
final bool isActive = index == selectedIndex;
|
||||
return InkWell(
|
||||
onTap: () => onTabChange(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isActive ? null : Border.all(color: Colors.black26),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.black,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
fontSize: 14,
|
||||
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
|
||||
final bool isActive = index == selectedIndex;
|
||||
return InkWell(
|
||||
onTap: enabled ? () => onTabChange(index) : null,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1.0 : 0.5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isActive ? null : Border.all(color: Colors.black26),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.black,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildMobileMenu(BuildContext context) {
|
||||
return PopupMenuButton<int>(
|
||||
icon: const Icon(Icons.menu, color: Colors.white),
|
||||
onSelected: (value) {
|
||||
if (value == 4) {
|
||||
_handleLogout(context);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 0, child: Text("Gestionnaires")),
|
||||
const PopupMenuItem(value: 1, child: Text("Parents")),
|
||||
const PopupMenuItem(value: 2, child: Text("Assistantes maternelles")),
|
||||
const PopupMenuItem(value: 3, child: Text("Administrateurs")),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(value: 4, child: Text("Se déconnecter")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,9 +116,10 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
// TODO: Implémenter la logique de déconnexion
|
||||
await AuthService.logout();
|
||||
if (context.mounted) context.go('/login');
|
||||
},
|
||||
child: const Text('Déconnecter'),
|
||||
),
|
||||
@@ -142,4 +127,65 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
|
||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final int selectedSubIndex;
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
|
||||
const DashboardUserManagementSubBar({
|
||||
Key? key,
|
||||
required this.selectedSubIndex,
|
||||
required this.onSubTabChange,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSubNavItem(context, 'Gestionnaires', 0),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubNavItem(context, 'Parents', 1),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubNavItem(context, 'Assistantes maternelles', 2),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubNavItem(context, 'Administrateurs', 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubNavItem(BuildContext context, String title, int index) {
|
||||
final bool isActive = index == selectedSubIndex;
|
||||
return InkWell(
|
||||
onTap: () => onSubTabChange(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isActive ? null : Border.all(color: Colors.black26),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.black87,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
|
||||
|
||||
class GestionnaireManagementWidget extends StatelessWidget {
|
||||
class GestionnaireManagementWidget extends StatefulWidget {
|
||||
const GestionnaireManagementWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GestionnaireManagementWidget> createState() =>
|
||||
_GestionnaireManagementWidgetState();
|
||||
}
|
||||
|
||||
class _GestionnaireManagementWidgetState
|
||||
extends State<GestionnaireManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AppUser> _gestionnaires = [];
|
||||
List<AppUser> _filteredGestionnaires = [];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGestionnaires();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadGestionnaires() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await UserService.getGestionnaires();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_gestionnaires = list;
|
||||
_filteredGestionnaires = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredGestionnaires = _gestionnaires.where((u) {
|
||||
final name = u.fullName.toLowerCase();
|
||||
final email = u.email.toLowerCase();
|
||||
return name.contains(query) || email.contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
@@ -14,9 +75,10 @@ class GestionnaireManagementWidget extends StatelessWidget {
|
||||
// 🔹 Barre du haut avec bouton
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Rechercher un gestionnaire...",
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
@@ -26,7 +88,7 @@ class GestionnaireManagementWidget extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Rediriger vers la page de création
|
||||
// TODO: Rediriger vers la page de création
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Créer un gestionnaire"),
|
||||
@@ -36,17 +98,25 @@ class GestionnaireManagementWidget extends StatelessWidget {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 🔹 Liste des gestionnaires
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5, // À remplacer par liste dynamique
|
||||
itemBuilder: (context, index) {
|
||||
return GestionnaireCard(
|
||||
name: "Dupont $index",
|
||||
email: "dupont$index@mail.com",
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_error != null)
|
||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||
else if (_filteredGestionnaires.isEmpty)
|
||||
const Center(child: Text("Aucun gestionnaire trouvé."))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredGestionnaires.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = _filteredGestionnaires[index];
|
||||
return GestionnaireCard(
|
||||
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
|
||||
email: user.email,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||
|
||||
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
||||
class ParametresPanel extends StatefulWidget {
|
||||
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
|
||||
final bool redirectToLoginAfterSave;
|
||||
|
||||
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
|
||||
|
||||
@override
|
||||
State<ParametresPanel> createState() => _ParametresPanelState();
|
||||
}
|
||||
|
||||
class _ParametresPanelState extends State<ParametresPanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = true;
|
||||
String? _loadError;
|
||||
bool _isSaving = false;
|
||||
String? _message;
|
||||
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
bool _smtpSecure = false;
|
||||
bool _smtpAuthRequired = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_createControllers();
|
||||
_loadConfiguration();
|
||||
}
|
||||
|
||||
void _createControllers() {
|
||||
final keys = [
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
|
||||
'email_from_name', 'email_from_address',
|
||||
'app_name', 'app_url', 'app_logo_url',
|
||||
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
|
||||
];
|
||||
for (final k in keys) {
|
||||
_controllers[k] = TextEditingController();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadConfiguration() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_loadError = null;
|
||||
});
|
||||
try {
|
||||
final list = await ConfigurationService.getAll();
|
||||
if (!mounted) return;
|
||||
for (final item in list) {
|
||||
final c = _controllers[item.cle];
|
||||
if (c != null && item.valeur != null && item.valeur != '***********') {
|
||||
c.text = item.valeur!;
|
||||
}
|
||||
if (item.cle == 'smtp_secure') {
|
||||
_smtpSecure = item.valeur == 'true';
|
||||
}
|
||||
if (item.cle == 'smtp_auth_required') {
|
||||
_smtpAuthRequired = item.valeur == 'true';
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_loadError = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final payload = <String, dynamic>{};
|
||||
payload['smtp_host'] = _controllers['smtp_host']!.text.trim();
|
||||
final port = int.tryParse(_controllers['smtp_port']!.text.trim());
|
||||
if (port != null) payload['smtp_port'] = port;
|
||||
payload['smtp_secure'] = _smtpSecure;
|
||||
payload['smtp_auth_required'] = _smtpAuthRequired;
|
||||
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
|
||||
final pwd = _controllers['smtp_password']!.text.trim();
|
||||
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
|
||||
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
|
||||
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
|
||||
payload['app_name'] = _controllers['app_name']!.text.trim();
|
||||
payload['app_url'] = _controllers['app_url']!.text.trim();
|
||||
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
|
||||
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
|
||||
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
|
||||
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
||||
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
|
||||
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
|
||||
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Sauvegarde en base sans completeSetup (utilisé avant test SMTP).
|
||||
Future<void> _saveBulkOnly() async {
|
||||
await ConfigurationService.updateBulk(_buildPayload());
|
||||
}
|
||||
|
||||
/// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login.
|
||||
Future<void> _save() async {
|
||||
final redirectAfter = widget.redirectToLoginAfterSave;
|
||||
setState(() {
|
||||
_message = null;
|
||||
_isSaving = true;
|
||||
});
|
||||
try {
|
||||
await ConfigurationService.updateBulk(_buildPayload());
|
||||
if (!mounted) return;
|
||||
await ConfigurationService.completeSetup();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_message = 'Configuration enregistrée.';
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (redirectAfter) {
|
||||
GoRouter.of(context).go('/login');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_message = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testSmtp() async {
|
||||
final email = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final c = TextEditingController();
|
||||
return AlertDialog(
|
||||
title: const Text('Tester la connexion SMTP'),
|
||||
content: TextField(
|
||||
controller: c,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email pour recevoir le test',
|
||||
hintText: 'admin@example.com',
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final t = c.text.trim();
|
||||
if (t.isNotEmpty) Navigator.pop(ctx, t);
|
||||
},
|
||||
child: const Text('Envoyer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (email == null || !mounted) return;
|
||||
setState(() => _message = null);
|
||||
try {
|
||||
await _saveBulkOnly();
|
||||
if (!mounted) return;
|
||||
final msg = await ConfigurationService.testSmtp(email);
|
||||
if (!mounted) return;
|
||||
setState(() => _message = msg);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _message = e.toString().replaceAll('Exception: ', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_loadError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_loadError!, style: TextStyle(color: Colors.red.shade700)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _loadConfiguration,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isSuccess = _message != null &&
|
||||
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_message != null) ...[
|
||||
_MessageBanner(message: _message!, isSuccess: isSuccess),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
_buildSectionCard(
|
||||
context,
|
||||
icon: Icons.email_outlined,
|
||||
title: 'Configuration Email (SMTP)',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _smtpSecure,
|
||||
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
|
||||
activeColor: const Color(0xFF9CC5C0),
|
||||
),
|
||||
const Text('SSL/TLS (secure)'),
|
||||
const SizedBox(width: 24),
|
||||
Checkbox(
|
||||
value: _smtpAuthRequired,
|
||||
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
|
||||
activeColor: const Color(0xFF9CC5C0),
|
||||
),
|
||||
const Text('Authentification requise'),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildField('smtp_user', 'Utilisateur SMTP'),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('email_from_name', 'Nom expéditeur'),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
|
||||
const SizedBox(height: 18),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isSaving ? null : _testSmtp,
|
||||
icon: const Icon(Icons.send_outlined, size: 18),
|
||||
label: const Text('Tester la connexion SMTP'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2D6A4F),
|
||||
side: const BorderSide(color: Color(0xFF9CC5C0)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionCard(
|
||||
context,
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Personnalisation',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildField('app_name', 'Nom de l\'application'),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionCard(
|
||||
context,
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Paramètres avancés',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
|
||||
const SizedBox(height: 14),
|
||||
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF9CC5C0),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Sauvegarder la configuration'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 22, color: const Color(0xFF9CC5C0)),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
|
||||
final c = _controllers[key];
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return TextFormField(
|
||||
controller: c,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboard,
|
||||
enabled: !_isSaving,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBanner extends StatelessWidget {
|
||||
final String message;
|
||||
final bool isSuccess;
|
||||
|
||||
const _MessageBanner({required this.message, required this.isSuccess});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSuccess ? Colors.green.shade50 : Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isSuccess ? Colors.green.shade200 : Colors.red.shade200,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
|
||||
size: 22,
|
||||
color: isSuccess ? Colors.green.shade700 : Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: isSuccess ? Colors.green.shade900 : Colors.red.shade900,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
|
||||
class ParentManagementWidget extends StatelessWidget {
|
||||
class ParentManagementWidget extends StatefulWidget {
|
||||
const ParentManagementWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔁 Simulation de données parents
|
||||
final parents = [
|
||||
{
|
||||
"nom": "Jean Dupuis",
|
||||
"email": "jean.dupuis@email.com",
|
||||
"statut": "Actif",
|
||||
"enfants": 2,
|
||||
},
|
||||
{
|
||||
"nom": "Lucie Morel",
|
||||
"email": "lucie.morel@email.com",
|
||||
"statut": "En attente",
|
||||
"enfants": 1,
|
||||
},
|
||||
];
|
||||
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
||||
}
|
||||
|
||||
class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<ParentModel> _parents = [];
|
||||
List<ParentModel> _filteredParents = [];
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String? _selectedStatus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadParents();
|
||||
_searchController.addListener(_filter);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadParents() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await UserService.getParents();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_parents = list;
|
||||
_filter(); // Apply initial filter (if any)
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _filter() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredParents = _parents.where((p) {
|
||||
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
||||
p.user.email.toLowerCase().contains(query);
|
||||
final matchesStatus = _selectedStatus == null ||
|
||||
_selectedStatus == 'Tous' ||
|
||||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
|
||||
|
||||
// Mapping simple pour le statut affiché vs backend
|
||||
// Backend: en_attente, actif, suspendu
|
||||
// Dropdown: En attente, Actif, Suspendu
|
||||
|
||||
return matchesName && matchesStatus;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
_buildSearchSection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: parents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final parent = parents[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: Text(parent['nom'].toString()),
|
||||
subtitle: Text(
|
||||
"${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}",
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility),
|
||||
tooltip: "Voir dossier",
|
||||
onPressed: () {
|
||||
// TODO: Voir le statut du dossier
|
||||
},
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSearchSection(),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_error != null)
|
||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||
else if (_filteredParents.isEmpty)
|
||||
const Center(child: Text("Aucun parent trouvé."))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredParents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final parent = _filteredParents[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundImage: parent.user.photoUrl != null
|
||||
? NetworkImage(parent.user.photoUrl!)
|
||||
: null,
|
||||
child: parent.user.photoUrl == null
|
||||
? const Icon(Icons.person)
|
||||
: null,
|
||||
),
|
||||
title: Text(parent.user.fullName.isNotEmpty
|
||||
? parent.user.fullName
|
||||
: 'Sans nom'),
|
||||
subtitle: Text(
|
||||
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility),
|
||||
tooltip: "Voir dossier",
|
||||
onPressed: () {
|
||||
// TODO: Voir le statut du dossier
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: "Modifier",
|
||||
onPressed: () {
|
||||
// TODO: Modifier parent
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: "Supprimer",
|
||||
onPressed: () {
|
||||
// TODO: Supprimer compte
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: "Modifier",
|
||||
onPressed: () {
|
||||
// TODO: Modifier parent
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: "Supprimer",
|
||||
onPressed: () {
|
||||
// TODO: Supprimer compte
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,13 +155,12 @@ class ParentManagementWidget extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Nom du parent",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// TODO: Ajouter logique de recherche
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
@@ -105,13 +170,18 @@ class ParentManagementWidget extends StatelessWidget {
|
||||
labelText: "Statut",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedStatus,
|
||||
items: const [
|
||||
DropdownMenuItem(value: "Actif", child: Text("Actif")),
|
||||
DropdownMenuItem(value: "En attente", child: Text("En attente")),
|
||||
DropdownMenuItem(value: "Supprimé", child: Text("Supprimé")),
|
||||
DropdownMenuItem(value: null, child: Text("Tous")),
|
||||
DropdownMenuItem(value: "actif", child: Text("Actif")),
|
||||
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
|
||||
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
|
||||
],
|
||||
onChanged: (value) {
|
||||
// TODO: Ajouter logique de filtrage
|
||||
setState(() {
|
||||
_selectedStatus = value;
|
||||
_filter();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ class AppCustomCheckbox extends StatelessWidget {
|
||||
final ValueChanged<bool> onChanged;
|
||||
final double checkboxSize;
|
||||
final double checkmarkSizeFactor;
|
||||
final double fontSize;
|
||||
|
||||
const AppCustomCheckbox({
|
||||
super.key,
|
||||
@@ -15,6 +16,7 @@ class AppCustomCheckbox extends StatelessWidget {
|
||||
required this.onChanged,
|
||||
this.checkboxSize = 20.0,
|
||||
this.checkmarkSizeFactor = 1.4,
|
||||
this.fontSize = 16.0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -51,7 +53,7 @@ class AppCustomCheckbox extends StatelessWidget {
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(fontSize: 16),
|
||||
style: GoogleFonts.merienda(fontSize: fontSize),
|
||||
overflow: TextOverflow.ellipsis, // Gérer le texte long
|
||||
),
|
||||
),
|
||||
|
||||
@@ -196,7 +196,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
|
||||
hintText: 'Retapez le nouveau mot de passe',
|
||||
obscureText: true,
|
||||
validator: _validateConfirmPassword,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
style: CustomAppTextFieldStyle.jaune,
|
||||
fieldHeight: 53,
|
||||
fieldWidth: double.infinity,
|
||||
enabled: !_isLoading,
|
||||
@@ -265,7 +265,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: ImageButton(
|
||||
bg: 'assets/images/btn_green.png',
|
||||
bg: 'assets/images/bg_green.png',
|
||||
width: 250,
|
||||
height: 40,
|
||||
text: 'Changer le mot de passe',
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../config/display_config.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import 'image_button.dart';
|
||||
|
||||
/// Widget de base générique pour tous les écrans de formulaire
|
||||
/// Gère automatiquement le layout, les boutons de navigation, etc.
|
||||
class BaseFormScreen extends StatelessWidget {
|
||||
/// Configuration d'affichage
|
||||
final DisplayConfig config;
|
||||
|
||||
/// Texte de l'étape (ex: "Étape 1/4")
|
||||
final String stepText;
|
||||
|
||||
/// Titre du formulaire
|
||||
final String title;
|
||||
|
||||
/// Couleur de la carte (horizontal pour desktop)
|
||||
final CardColorHorizontal cardColor;
|
||||
|
||||
/// Contenu du formulaire
|
||||
final Widget content;
|
||||
|
||||
/// Texte du bouton de soumission (par défaut "Suivant")
|
||||
final String? submitButtonText;
|
||||
|
||||
/// Callback de soumission
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
/// Route précédente (pour le bouton retour)
|
||||
final String previousRoute;
|
||||
|
||||
/// Widget supplémentaire au-dessus du contenu (ex: toggle)
|
||||
final Widget? headerWidget;
|
||||
|
||||
/// Widget supplémentaire en dessous du contenu (ex: checkbox CGU)
|
||||
final Widget? footerWidget;
|
||||
|
||||
/// Padding personnalisé pour le contenu
|
||||
final EdgeInsets? contentPadding;
|
||||
|
||||
const BaseFormScreen({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.stepText,
|
||||
required this.title,
|
||||
required this.cardColor,
|
||||
required this.content,
|
||||
required this.onSubmit,
|
||||
required this.previousRoute,
|
||||
this.submitButtonText,
|
||||
this.headerWidget,
|
||||
this.footerWidget,
|
||||
this.contentPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFFF8E1),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(
|
||||
LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 16.0,
|
||||
desktopSpacing: 32.0,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: LayoutHelper.getMaxWidth(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Texte de l'étape
|
||||
Text(
|
||||
stepText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF6D4C41),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 24 : 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF4A4A4A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Header widget (si fourni)
|
||||
if (headerWidget != null) ...[
|
||||
headerWidget!,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Carte principale
|
||||
_buildCard(context),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Footer widget (si fourni)
|
||||
if (footerWidget != null) ...[
|
||||
footerWidget!,
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Boutons de navigation
|
||||
_buildNavigationButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la carte principale
|
||||
Widget _buildCard(BuildContext context) {
|
||||
final effectivePadding = contentPadding ??
|
||||
EdgeInsets.all(
|
||||
LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 16.0,
|
||||
desktopSpacing: 32.0,
|
||||
),
|
||||
);
|
||||
|
||||
if (config.isMobile) {
|
||||
// Carte verticale sur mobile
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(_getVerticalCardAsset()),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: effectivePadding,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Carte horizontale sur desktop
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(cardColor.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: effectivePadding,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
// Mapping couleur horizontale -> verticale
|
||||
switch (cardColor) {
|
||||
case CardColorHorizontal.blue:
|
||||
return CardColorVertical.blue.path;
|
||||
case CardColorHorizontal.green:
|
||||
return CardColorVertical.green.path;
|
||||
case CardColorHorizontal.lavender:
|
||||
return CardColorVertical.lavender.path;
|
||||
case CardColorHorizontal.lime:
|
||||
return CardColorVertical.lime.path;
|
||||
case CardColorHorizontal.peach:
|
||||
return CardColorVertical.peach.path;
|
||||
case CardColorHorizontal.pink:
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit les boutons de navigation
|
||||
Widget _buildNavigationButtons(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Bouton Précédent
|
||||
HoverReliefWidget(
|
||||
child: ImageButton(
|
||||
bg: 'assets/images/bg_green.png',
|
||||
text: 'Précédent',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => Navigator.pushNamed(context, previousRoute),
|
||||
width: config.isMobile ? 120 : 150,
|
||||
height: config.isMobile ? 40 : 50,
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton Suivant/Soumettre
|
||||
HoverReliefWidget(
|
||||
child: ImageButton(
|
||||
bg: 'assets/images/bg_green.png',
|
||||
text: submitButtonText ?? 'Suivant',
|
||||
textColor: Colors.white,
|
||||
onPressed: config.isReadonly ? onSubmit : () {
|
||||
// En mode éditable, valider avant de soumettre
|
||||
onSubmit();
|
||||
},
|
||||
width: config.isMobile ? 120 : 150,
|
||||
height: config.isMobile ? 40 : 50,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'dart:io' show File;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../models/user_registration_data.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'app_custom_checkbox.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import '../config/display_config.dart';
|
||||
|
||||
/// Widget pour afficher et éditer une carte enfant
|
||||
/// Utilisé dans le workflow d'inscription des parents
|
||||
class ChildCardWidget extends StatefulWidget {
|
||||
final ChildData childData;
|
||||
final int childIndex;
|
||||
final VoidCallback onPickImage;
|
||||
final VoidCallback onDateSelect;
|
||||
final ValueChanged<String> onFirstNameChanged;
|
||||
final ValueChanged<String> onLastNameChanged;
|
||||
final ValueChanged<bool> onTogglePhotoConsent;
|
||||
final ValueChanged<bool> onToggleMultipleBirth;
|
||||
final ValueChanged<bool> onToggleIsUnborn;
|
||||
final VoidCallback onRemove;
|
||||
final bool canBeRemoved;
|
||||
final DisplayMode mode;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const ChildCardWidget({
|
||||
required Key key,
|
||||
required this.childData,
|
||||
required this.childIndex,
|
||||
required this.onPickImage,
|
||||
required this.onDateSelect,
|
||||
required this.onFirstNameChanged,
|
||||
required this.onLastNameChanged,
|
||||
required this.onTogglePhotoConsent,
|
||||
required this.onToggleMultipleBirth,
|
||||
required this.onToggleIsUnborn,
|
||||
required this.onRemove,
|
||||
required this.canBeRemoved,
|
||||
this.mode = DisplayMode.editable,
|
||||
this.onEdit,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ChildCardWidget> createState() => _ChildCardWidgetState();
|
||||
}
|
||||
|
||||
class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
late TextEditingController _firstNameController;
|
||||
late TextEditingController _lastNameController;
|
||||
late TextEditingController _dobController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les contrôleurs avec les données du widget
|
||||
_firstNameController = TextEditingController(text: widget.childData.firstName);
|
||||
_lastNameController = TextEditingController(text: widget.childData.lastName);
|
||||
_dobController = TextEditingController(text: widget.childData.dob);
|
||||
|
||||
// Ajouter des listeners pour mettre à jour les données sources via les callbacks
|
||||
_firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text));
|
||||
_lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text));
|
||||
// Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChildCardWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Mettre à jour les contrôleurs si les données externes changent
|
||||
// (peut arriver si on recharge l'état global)
|
||||
if (widget.childData.firstName != _firstNameController.text) {
|
||||
_firstNameController.text = widget.childData.firstName;
|
||||
}
|
||||
if (widget.childData.lastName != _lastNameController.text) {
|
||||
_lastNameController.text = widget.childData.lastName;
|
||||
}
|
||||
if (widget.childData.dob != _dobController.text) {
|
||||
_dobController.text = widget.childData.dob;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_dobController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = DisplayConfig.fromContext(context, mode: widget.mode);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final scaleFactor = config.isMobile ? 0.9 : 1.1; // Réduire légèrement sur mobile
|
||||
|
||||
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal
|
||||
if (config.isReadonly && !config.isMobile) {
|
||||
return _buildReadonlyDesktopCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical (1:2)
|
||||
if (config.isReadonly && config.isMobile) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: _buildReadonlyMobileCard(context, config),
|
||||
);
|
||||
}
|
||||
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
// ... (reste du code existant pour mobile/editable)
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.shade200
|
||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
|
||||
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
|
||||
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
|
||||
|
||||
return Container(
|
||||
width: config.isMobile ? double.infinity : screenSize.width * 0.6,
|
||||
// On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes
|
||||
// height: config.isMobile ? null : 600.0 * scaleFactor,
|
||||
padding: EdgeInsets.all(22.0 * scaleFactor),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage(widget.childData.cardColor.path), fit: BoxFit.fill),
|
||||
borderRadius: BorderRadius.circular(20 * scaleFactor),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ... (contenu existant)
|
||||
HoverReliefWidget(
|
||||
onPressed: config.isReadonly ? null : widget.onPickImage,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
child: SizedBox(
|
||||
height: 200.0 * (config.isMobile ? 0.8 : 1.0),
|
||||
width: 200.0 * (config.isMobile ? 0.8 : 1.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(5.0 * scaleFactor),
|
||||
child: currentChildImage != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.0 * scaleFactor),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Enfant à naître ?',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 14 : 16 * scaleFactor,
|
||||
fontWeight: FontWeight.w600
|
||||
)
|
||||
),
|
||||
Transform.scale(
|
||||
scale: config.isMobile ? 0.8 : 1.0,
|
||||
child: Switch(
|
||||
value: widget.childData.isUnbornChild,
|
||||
onChanged: config.isReadonly ? null : widget.onToggleIsUnborn,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.0 * scaleFactor),
|
||||
_buildField(
|
||||
config: config,
|
||||
scaleFactor: scaleFactor,
|
||||
label: 'Prénom',
|
||||
controller: _firstNameController,
|
||||
hint: 'Facultatif si à naître',
|
||||
isRequired: !widget.childData.isUnbornChild,
|
||||
),
|
||||
SizedBox(height: 5.0 * scaleFactor),
|
||||
_buildField(
|
||||
config: config,
|
||||
scaleFactor: scaleFactor,
|
||||
label: 'Nom',
|
||||
controller: _lastNameController,
|
||||
hint: 'Nom de l\'enfant',
|
||||
),
|
||||
SizedBox(height: 8.0 * scaleFactor),
|
||||
_buildField(
|
||||
config: config,
|
||||
scaleFactor: scaleFactor,
|
||||
label: widget.childData.isUnbornChild ? 'Date prévisionnelle de naissance' : 'Date de naissance',
|
||||
controller: _dobController,
|
||||
hint: 'JJ/MM/AAAA',
|
||||
readOnly: true,
|
||||
onTap: config.isReadonly ? null : widget.onDateSelect,
|
||||
suffixIcon: Icons.calendar_today,
|
||||
),
|
||||
SizedBox(height: 10.0 * scaleFactor),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Consentement photo',
|
||||
value: widget.childData.photoConsent,
|
||||
onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent,
|
||||
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
|
||||
fontSize: config.isMobile ? 13.0 : 16.0,
|
||||
),
|
||||
SizedBox(height: 5.0 * scaleFactor),
|
||||
AppCustomCheckbox(
|
||||
label: 'Naissance multiple',
|
||||
value: widget.childData.multipleBirth,
|
||||
onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth,
|
||||
checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor,
|
||||
fontSize: config.isMobile ? 13.0 : 16.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.canBeRemoved && !config.isReadonly)
|
||||
Positioned(
|
||||
top: -5, right: -5,
|
||||
child: InkWell(
|
||||
onTap: widget.onRemove,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Image.asset(
|
||||
'assets/images/red_cross2.png',
|
||||
width: config.isMobile ? 30 : 36,
|
||||
height: config.isMobile ? 30 : 36,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (config.isReadonly && widget.onEdit != null)
|
||||
Positioned(
|
||||
top: -5, right: -5,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
|
||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
|
||||
// On mappe les couleurs verticales vers horizontales
|
||||
String horizontalCardAsset = CardColorHorizontal.lavender.path; // Par défaut
|
||||
|
||||
// Mapping manuel simple
|
||||
if (widget.childData.cardColor.path.contains('lavender')) horizontalCardAsset = CardColorHorizontal.lavender.path;
|
||||
else if (widget.childData.cardColor.path.contains('blue')) horizontalCardAsset = CardColorHorizontal.blue.path;
|
||||
else if (widget.childData.cardColor.path.contains('green')) horizontalCardAsset = CardColorHorizontal.green.path;
|
||||
else if (widget.childData.cardColor.path.contains('lime')) horizontalCardAsset = CardColorHorizontal.lime.path;
|
||||
else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path;
|
||||
else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path;
|
||||
else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path;
|
||||
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
final cardWidth = screenSize.width / 2.0;
|
||||
|
||||
return SizedBox(
|
||||
width: cardWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(horizontalCardAsset),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enfant ${widget.childIndex + 1}' + (widget.childData.isUnbornChild ? ' (à naître)' : ''),
|
||||
style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Contenu principal : Photo + Champs
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// PHOTO (1/3)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: currentChildImage != null
|
||||
? (kIsWeb
|
||||
? Image.network(currentChildImage.path, fit: BoxFit.cover)
|
||||
: Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// CHAMPS (2/3)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildReadonlyField('Prénom :', _firstNameController.text),
|
||||
const SizedBox(height: 12),
|
||||
_buildReadonlyField('Nom :', _lastNameController.text),
|
||||
const SizedBox(height: 12),
|
||||
_buildReadonlyField(
|
||||
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
|
||||
_dobController.text
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Consentements
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Consentement photo',
|
||||
value: widget.childData.photoConsent,
|
||||
onChanged: (v) {}, // Readonly
|
||||
checkboxSize: 22.0,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
AppCustomCheckbox(
|
||||
label: 'Naissance multiple',
|
||||
value: widget.childData.multipleBirth,
|
||||
onChanged: (v) {}, // Readonly
|
||||
checkboxSize: 22.0,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||||
Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) {
|
||||
final File? currentChildImage = widget.childData.imageFile;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// Pas de height fixe
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(widget.childData.cardColor.path), // Image verticale
|
||||
fit: BoxFit.fill, // Fill pour s'adapter
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min, // S'adapte au contenu
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enfant ${widget.childIndex + 1}' + (widget.childData.isUnbornChild ? ' (à naître)' : ''),
|
||||
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
const SizedBox(width: 28),
|
||||
],
|
||||
),
|
||||
|
||||
// Contenu aligné en haut
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Photo
|
||||
SizedBox(
|
||||
height: 150,
|
||||
width: 150,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: currentChildImage != null
|
||||
? (kIsWeb
|
||||
? Image.network(currentChildImage.path, fit: BoxFit.cover)
|
||||
: Image.file(currentChildImage, fit: BoxFit.cover))
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Champs
|
||||
_buildReadonlyField('Prénom :', _firstNameController.text),
|
||||
const SizedBox(height: 8),
|
||||
_buildReadonlyField('Nom :', _lastNameController.text),
|
||||
const SizedBox(height: 8),
|
||||
_buildReadonlyField(
|
||||
widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :',
|
||||
_dobController.text
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Consentements
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Consentement photo',
|
||||
value: widget.childData.photoConsent,
|
||||
onChanged: (v) {},
|
||||
checkboxSize: 20.0,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
AppCustomCheckbox(
|
||||
label: 'Naissance multiple',
|
||||
value: widget.childData.multipleBirth,
|
||||
onChanged: (v) {},
|
||||
checkboxSize: 20.0,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper pour champ Readonly style "Beige"
|
||||
Widget _buildReadonlyField(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(fontSize: 22.0, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 50.0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/bg_beige.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: 18.0),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required DisplayConfig config,
|
||||
required double scaleFactor,
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
bool isRequired = false,
|
||||
bool readOnly = false,
|
||||
VoidCallback? onTap,
|
||||
IconData? suffixIcon,
|
||||
}) {
|
||||
if (config.isReadonly) {
|
||||
return FormFieldWrapper(
|
||||
config: config,
|
||||
label: label,
|
||||
value: controller.text,
|
||||
);
|
||||
} else {
|
||||
return CustomAppTextField(
|
||||
controller: controller,
|
||||
labelText: label,
|
||||
hintText: hint ?? label,
|
||||
isRequired: isRequired,
|
||||
fieldHeight: config.isMobile ? 40.0 : 50.0 * scaleFactor, // Hauteur réduite
|
||||
labelFontSize: config.isMobile ? 12.0 : 18.0, // Police réduite
|
||||
inputFontSize: config.isMobile ? 13.0 : 16.0, // Police réduite
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
suffixIcon: suffixIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
|
||||
/// Widget réutilisable pour la carte de choix Parent/AM
|
||||
class ChoiceCardWidget extends StatelessWidget {
|
||||
final VoidCallback onParentSelected;
|
||||
final VoidCallback onAmSelected;
|
||||
final bool isMobile;
|
||||
|
||||
const ChoiceCardWidget({
|
||||
super.key,
|
||||
required this.onParentSelected,
|
||||
required this.onAmSelected,
|
||||
this.isMobile = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(CardColorVertical.pink.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: isMobile ? MainAxisAlignment.center : MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildChoiceButton(
|
||||
iconPath: 'assets/images/icon_parents.png',
|
||||
label: 'Parents',
|
||||
onPressed: onParentSelected,
|
||||
isMobile: isMobile,
|
||||
),
|
||||
SizedBox(height: isMobile ? 30 : 0),
|
||||
_buildChoiceButton(
|
||||
iconPath: 'assets/images/icon_assmat.png',
|
||||
label: 'Assistante Maternelle',
|
||||
onPressed: onAmSelected,
|
||||
isMobile: isMobile,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChoiceButton({
|
||||
required String iconPath,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
required bool isMobile,
|
||||
}) {
|
||||
final Color baseRoseColor = Colors.pink.shade300;
|
||||
final Color initialShadow = isMobile
|
||||
? Colors.black.withOpacity(0.45)
|
||||
: baseRoseColor.withAlpha(90);
|
||||
final Color hoverShadow = isMobile
|
||||
? Colors.black.withOpacity(0.5)
|
||||
: baseRoseColor.withAlpha(130);
|
||||
final double initialElevation = isMobile ? 14.0 : 4.0;
|
||||
final double hoverElevation = isMobile ? 18.0 : 8.0;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: onPressed,
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
initialElevation: initialElevation,
|
||||
hoverElevation: hoverElevation,
|
||||
initialShadowColor: initialShadow,
|
||||
hoverShadowColor: hoverShadow,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isMobile ? 6.0 : 8.0),
|
||||
child: Image.asset(iconPath, height: isMobile ? 140 : 170),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isMobile ? 10 : 15),
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: isMobile ? 20 : 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black.withOpacity(0.85),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,13 @@ class CustomAppTextField extends StatefulWidget {
|
||||
final IconData? suffixIcon;
|
||||
final double labelFontSize;
|
||||
final double inputFontSize;
|
||||
final bool showLabel;
|
||||
|
||||
const CustomAppTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
this.showLabel = true,
|
||||
this.hintText = '',
|
||||
this.fieldWidth = 300.0,
|
||||
this.fieldHeight = 53.0,
|
||||
@@ -54,12 +56,12 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
String getBackgroundImagePath() {
|
||||
switch (widget.style) {
|
||||
case CustomAppTextFieldStyle.lavande:
|
||||
return 'assets/images/input_field_lavande.png';
|
||||
return 'assets/images/bg_lavender.png';
|
||||
case CustomAppTextFieldStyle.jaune:
|
||||
return 'assets/images/input_field_jaune.png';
|
||||
return 'assets/images/bg_yellow.png';
|
||||
case CustomAppTextFieldStyle.beige:
|
||||
default:
|
||||
return 'assets/images/input_field_bg.png';
|
||||
return 'assets/images/bg_beige.png';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +75,17 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.labelText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: widget.labelFontSize,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
if (widget.showLabel) ...[
|
||||
Text(
|
||||
widget.labelText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: widget.labelFontSize,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
SizedBox(
|
||||
width: widget.fieldWidth,
|
||||
height: dynamicFieldHeight,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// Style de bouton de navigation
|
||||
enum NavigationButtonStyle {
|
||||
green, // Bouton vert avec texte vert foncé
|
||||
purple, // Bouton violet avec texte violet foncé
|
||||
}
|
||||
|
||||
/// Widget de bouton de navigation personnalisé
|
||||
/// Utilise les assets existants pour le fond
|
||||
class CustomNavigationButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
final NavigationButtonStyle style;
|
||||
final double? width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
|
||||
const CustomNavigationButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.style = NavigationButtonStyle.green,
|
||||
this.width,
|
||||
this.height = 50,
|
||||
this.fontSize = 16,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundImage = _getBackgroundImage();
|
||||
final textColor = _getTextColor();
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Fond avec image
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
backgroundImage,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
// Bouton cliquable
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: GoogleFonts.merienda(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getBackgroundImage() {
|
||||
switch (style) {
|
||||
case NavigationButtonStyle.green:
|
||||
return 'assets/images/bg_green.png';
|
||||
case NavigationButtonStyle.purple:
|
||||
return 'assets/images/bg_lavender.png';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTextColor() {
|
||||
switch (style) {
|
||||
case NavigationButtonStyle.green:
|
||||
return const Color(0xFF2E7D32); // Vert foncé
|
||||
case NavigationButtonStyle.purple:
|
||||
return const Color(0xFF5E35B1); // Violet foncé
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../config/display_config.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
|
||||
/// Widget générique pour afficher un champ de formulaire
|
||||
/// S'adapte automatiquement selon le DisplayConfig (editable/readonly, mobile/desktop)
|
||||
class FormFieldWrapper extends StatelessWidget {
|
||||
/// Configuration d'affichage
|
||||
final DisplayConfig config;
|
||||
|
||||
/// Label du champ
|
||||
final String label;
|
||||
|
||||
/// Valeur actuelle
|
||||
final String value;
|
||||
|
||||
/// Controller pour le mode éditable
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Callback de changement (mode éditable)
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
/// Hint du champ (mode éditable)
|
||||
final String? hint;
|
||||
|
||||
/// Nombre de lignes (pour textarea)
|
||||
final int? maxLines;
|
||||
|
||||
/// Type de clavier
|
||||
final TextInputType? keyboardType;
|
||||
|
||||
/// Widget personnalisé à afficher (override le champ standard)
|
||||
final Widget? customWidget;
|
||||
|
||||
const FormFieldWrapper({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.controller,
|
||||
this.onChanged,
|
||||
this.hint,
|
||||
this.maxLines,
|
||||
this.keyboardType,
|
||||
this.customWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (config.isReadonly) {
|
||||
return _buildReadonlyField(context);
|
||||
} else {
|
||||
return _buildEditableField(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit un champ en mode lecture seule
|
||||
Widget _buildReadonlyField(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 8.0,
|
||||
desktopSpacing: 12.0,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF4A4A4A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Valeur avec fond beige
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/bg_beige.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
color: const Color(0xFF2C2C2C),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un champ en mode éditable
|
||||
Widget _buildEditableField(BuildContext context) {
|
||||
// Si un widget personnalisé est fourni, l'utiliser
|
||||
if (customWidget != null) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 8.0,
|
||||
desktopSpacing: 12.0,
|
||||
),
|
||||
),
|
||||
child: customWidget,
|
||||
);
|
||||
}
|
||||
|
||||
// Sinon, utiliser le champ standard
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 8.0,
|
||||
desktopSpacing: 12.0,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF4A4A4A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Champ de saisie
|
||||
CustomAppTextField(
|
||||
controller: controller!,
|
||||
labelText: label,
|
||||
hintText: hint ?? label,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget générique pour afficher une ligne de champs
|
||||
/// S'adapte automatiquement: horizontal sur desktop, vertical sur mobile
|
||||
class FormFieldRow extends StatelessWidget {
|
||||
/// Configuration d'affichage
|
||||
final DisplayConfig config;
|
||||
|
||||
/// Liste des champs à afficher
|
||||
final List<Widget> fields;
|
||||
|
||||
/// Espacement entre les champs
|
||||
final double? spacing;
|
||||
|
||||
const FormFieldRow({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.fields,
|
||||
this.spacing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveSpacing = spacing ??
|
||||
LayoutHelper.getSpacing(context,
|
||||
mobileSpacing: 12.0,
|
||||
desktopSpacing: 20.0,
|
||||
);
|
||||
|
||||
if (config.isMobile) {
|
||||
// Layout vertical sur mobile
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: fields,
|
||||
);
|
||||
} else {
|
||||
// Layout horizontal sur desktop
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < fields.length; i++) ...[
|
||||
Expanded(child: fields[i]),
|
||||
if (i < fields.length - 1) SizedBox(width: effectiveSpacing),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'custom_app_text_field.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import 'custom_navigation_button.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import '../config/display_config.dart';
|
||||
|
||||
/// Modèle de données pour le formulaire
|
||||
class PersonalInfoData {
|
||||
String firstName;
|
||||
String lastName;
|
||||
String phone;
|
||||
String email;
|
||||
String address;
|
||||
String postalCode;
|
||||
String city;
|
||||
|
||||
PersonalInfoData({
|
||||
this.firstName = '',
|
||||
this.lastName = '',
|
||||
this.phone = '',
|
||||
this.email = '',
|
||||
this.address = '',
|
||||
this.postalCode = '',
|
||||
this.city = '',
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget générique pour les formulaires d'informations personnelles
|
||||
/// Supporte mode éditable et readonly, responsive mobile/desktop
|
||||
class PersonalInfoFormScreen extends StatefulWidget {
|
||||
final DisplayMode mode; // editable ou readonly
|
||||
final String stepText; // Ex: "Étape 1/5"
|
||||
final String title; // Ex: "Informations du Parent Principal"
|
||||
final CardColorHorizontal cardColor;
|
||||
final PersonalInfoData initialData;
|
||||
final Function(PersonalInfoData data, {bool? hasSecondPerson, bool? sameAddress}) onSubmit;
|
||||
final String previousRoute;
|
||||
|
||||
// Options spécifiques pour Parent 2
|
||||
final bool showSecondPersonToggle; // Afficher "Il y a un 2ème parent"
|
||||
final bool? initialHasSecondPerson;
|
||||
final bool showSameAddressCheckbox; // Afficher "Même adresse que parent 1"
|
||||
final bool? initialSameAddress;
|
||||
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
|
||||
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
|
||||
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
|
||||
|
||||
const PersonalInfoFormScreen({
|
||||
super.key,
|
||||
this.mode = DisplayMode.editable,
|
||||
required this.stepText,
|
||||
required this.title,
|
||||
required this.cardColor,
|
||||
required this.initialData,
|
||||
required this.onSubmit,
|
||||
required this.previousRoute,
|
||||
this.showSecondPersonToggle = false,
|
||||
this.initialHasSecondPerson,
|
||||
this.showSameAddressCheckbox = false,
|
||||
this.initialSameAddress,
|
||||
this.referenceAddressData,
|
||||
this.embedContentOnly = false,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PersonalInfoFormScreen> createState() => _PersonalInfoFormScreenState();
|
||||
}
|
||||
|
||||
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _lastNameController;
|
||||
late TextEditingController _firstNameController;
|
||||
late TextEditingController _phoneController;
|
||||
late TextEditingController _emailController;
|
||||
late TextEditingController _addressController;
|
||||
late TextEditingController _postalCodeController;
|
||||
late TextEditingController _cityController;
|
||||
|
||||
bool _hasSecondPerson = false;
|
||||
bool _sameAddress = false;
|
||||
bool _fieldsEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_lastNameController = TextEditingController(text: widget.initialData.lastName);
|
||||
_firstNameController = TextEditingController(text: widget.initialData.firstName);
|
||||
_phoneController = TextEditingController(text: widget.initialData.phone);
|
||||
_emailController = TextEditingController(text: widget.initialData.email);
|
||||
_addressController = TextEditingController(text: widget.initialData.address);
|
||||
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
|
||||
_cityController = TextEditingController(text: widget.initialData.city);
|
||||
|
||||
if (widget.showSecondPersonToggle) {
|
||||
_hasSecondPerson = widget.initialHasSecondPerson ?? true;
|
||||
_fieldsEnabled = _hasSecondPerson;
|
||||
}
|
||||
|
||||
if (widget.showSameAddressCheckbox) {
|
||||
_sameAddress = widget.initialSameAddress ?? false;
|
||||
_updateAddressFields();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lastNameController.dispose();
|
||||
_firstNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_addressController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateAddressFields() {
|
||||
if (_sameAddress && widget.referenceAddressData != null) {
|
||||
_addressController.text = widget.referenceAddressData!.address;
|
||||
_postalCodeController.text = widget.referenceAddressData!.postalCode;
|
||||
_cityController.text = widget.referenceAddressData!.city;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
|
||||
final data = PersonalInfoData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
phone: _phoneController.text,
|
||||
email: _emailController.text,
|
||||
address: _addressController.text,
|
||||
postalCode: _postalCodeController.text,
|
||||
city: _cityController.text,
|
||||
);
|
||||
|
||||
widget.onSubmit(
|
||||
data,
|
||||
hasSecondPerson: widget.showSecondPersonToggle ? _hasSecondPerson : null,
|
||||
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final config = DisplayConfig.fromContext(context, mode: widget.mode);
|
||||
|
||||
if (widget.embedContentOnly) {
|
||||
return _buildCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.stepText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 13 : 16,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: config.isMobile ? 6 : 10),
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 18 : 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: config.isMobile ? 16 : 30),
|
||||
_buildCard(context, config, screenSize),
|
||||
|
||||
// Boutons mobile sous la carte (dans le scroll)
|
||||
if (config.isMobile) ...[
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté)
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Écart entre les boutons
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Suivant',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: _handleSubmit,
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons de navigation (desktop uniquement)
|
||||
if (!config.isMobile) ...[
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _handleSubmit,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// En mode readonly desktop avec embedContentOnly, utiliser le layout ancien (AspectRatio 2:1)
|
||||
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
|
||||
return _buildReadonlyDesktopCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
// En mode readonly mobile avec embedContentOnly, forcer le ratio 1:2 (Vertical)
|
||||
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05), // Marge 5%
|
||||
child: _buildMobileReadonlyCard(context, config, screenSize),
|
||||
);
|
||||
}
|
||||
|
||||
// Mode normal (éditable ou mobile non-récap)
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
|
||||
horizontal: config.isMobile ? 24 : 50,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
config.isMobile
|
||||
? _getVerticalCardAsset()
|
||||
: widget.cardColor.path
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.embedContentOnly) ...[
|
||||
Text(
|
||||
// Titre raccourci sur mobile en mode readonly pour laisser place au bouton edit
|
||||
(config.isMobile && config.isReadonly && widget.title.contains('Parent Principal'))
|
||||
? 'Parent Principal'
|
||||
: widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 18 : 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
if (config.isEditable && widget.showSecondPersonToggle)
|
||||
_buildToggles(context, config),
|
||||
|
||||
_buildFormFields(context, config),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (config.isReadonly && widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||||
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// Pas de height fixe, s'adapte au contenu
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(_getVerticalCardAsset()),
|
||||
fit: BoxFit.fill, // Fill pour que l'image s'étire selon la hauteur du contenu
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min, // Important : prend le min de place
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
(widget.title.contains('Parent Principal') ? 'Parent Principal' : (widget.title.contains('Deuxième Parent') ? 'Deuxième Parent' : widget.title)),
|
||||
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
const SizedBox(width: 28),
|
||||
],
|
||||
),
|
||||
|
||||
// Contenu aligné en haut (plus d'Expanded ici car parent non contraint)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: _buildFormFields(context, config),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly desktop avec AspectRatio 2:1 (format de l'ancien récapitulatif)
|
||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Largeur de la carte : 50% de l'écran (comme l'ancien système)
|
||||
final cardWidth = screenSize.width / 2.0;
|
||||
|
||||
return SizedBox(
|
||||
width: cardWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(widget.cardColor.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre centré
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 55), // Espace très augmenté pour pousser les champs vers le bas
|
||||
// Champs du formulaire
|
||||
_buildFormFields(context, config),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bouton d'édition à droite
|
||||
if (widget.onEdit != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les toggles (Parent 2 / Même adresse)
|
||||
Widget _buildToggles(BuildContext context, DisplayConfig config) {
|
||||
if (config.isMobile) {
|
||||
// Layout vertical sur mobile - toggles compacts
|
||||
return Column(
|
||||
children: [
|
||||
_buildSecondPersonToggle(context, config),
|
||||
if (widget.showSameAddressCheckbox) ...[
|
||||
const SizedBox(height: 5), // Réduit de 12 à 5
|
||||
_buildSameAddressToggle(context, config),
|
||||
],
|
||||
const SizedBox(height: 10), // Réduit de 24 à 10
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Layout horizontal sur desktop
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: _buildSecondPersonToggle(context, config),
|
||||
),
|
||||
const Expanded(flex: 1, child: SizedBox()),
|
||||
if (widget.showSameAddressCheckbox)
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: _buildSameAddressToggle(context, config),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSecondPersonToggle(BuildContext context, DisplayConfig config) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.person_add_alt_1, size: config.isMobile ? 18 : 20),
|
||||
SizedBox(width: config.isMobile ? 6 : 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ajouter Parent 2 ?',
|
||||
style: GoogleFonts.merienda(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: config.isMobile ? 0.85 : 1.0,
|
||||
child: Switch(
|
||||
value: _hasSecondPerson,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_hasSecondPerson = value;
|
||||
_fieldsEnabled = value;
|
||||
});
|
||||
},
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSameAddressToggle(BuildContext context, DisplayConfig config) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_work_outlined,
|
||||
size: config.isMobile ? 18 : 20,
|
||||
color: _fieldsEnabled ? null : Colors.grey,
|
||||
),
|
||||
SizedBox(width: config.isMobile ? 6 : 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Même Adresse ?',
|
||||
style: GoogleFonts.merienda(
|
||||
color: _fieldsEnabled ? null : Colors.grey,
|
||||
fontSize: config.isMobile ? 14 : 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: config.isMobile ? 0.85 : 1.0,
|
||||
child: Switch(
|
||||
value: _sameAddress,
|
||||
onChanged: _fieldsEnabled ? (value) {
|
||||
setState(() {
|
||||
_sameAddress = value;
|
||||
_updateAddressFields();
|
||||
});
|
||||
} : null,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les champs du formulaire avec la nouvelle infrastructure
|
||||
Widget _buildFormFields(BuildContext context, DisplayConfig config) {
|
||||
if (config.isMobile) {
|
||||
return _buildMobileFields(context, config);
|
||||
} else {
|
||||
return _buildDesktopFields(context, config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout DESKTOP : champs côte à côte (horizontal)
|
||||
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
|
||||
// En mode readonly, utiliser l'ancien layout du récapitulatif
|
||||
if (config.isReadonly) {
|
||||
return _buildReadonlyDesktopFields(context);
|
||||
}
|
||||
|
||||
// Mode éditable : layout normal
|
||||
final double verticalSpacing = 32.0;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Nom et Prénom
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Nom',
|
||||
controller: _lastNameController,
|
||||
hint: 'Votre nom de famille',
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Prénom',
|
||||
controller: _firstNameController,
|
||||
hint: 'Votre prénom',
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
|
||||
// Téléphone et Email
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Téléphone',
|
||||
controller: _phoneController,
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
hint: 'Votre adresse e-mail',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
|
||||
// Adresse
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Adresse (N° et Rue)',
|
||||
controller: _addressController,
|
||||
hint: 'Numéro et nom de votre rue',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
|
||||
// Code Postal et Ville
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Code Postal',
|
||||
controller: _postalCodeController,
|
||||
hint: 'Code postal',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Ville',
|
||||
controller: _cityController,
|
||||
hint: 'Votre ville',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout DESKTOP en mode READONLY : réplique l'ancien design du récapitulatif
|
||||
Widget _buildReadonlyDesktopFields(BuildContext context) {
|
||||
const double verticalSpacing = 20.0; // Réduit pour compacter le bas
|
||||
const double labelFontSize = 22.0;
|
||||
const double valueFontSize = 18.0; // Taille du texte dans les champs
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Ligne 1 : Nom + Prénom
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDisplayFieldValue(
|
||||
context,
|
||||
'Nom :',
|
||||
_lastNameController.text,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildDisplayFieldValue(
|
||||
context,
|
||||
'Prénom :',
|
||||
_firstNameController.text,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
|
||||
// Ligne 2 : Téléphone + Email
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDisplayFieldValue(
|
||||
context,
|
||||
'Téléphone :',
|
||||
_phoneController.text,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildDisplayFieldValue(
|
||||
context,
|
||||
'Email :',
|
||||
_emailController.text,
|
||||
multiLine: true,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: verticalSpacing),
|
||||
|
||||
// Ligne 3 : Adresse complète (adresse + CP + ville)
|
||||
_buildDisplayFieldValue(
|
||||
context,
|
||||
'Adresse :',
|
||||
"${_addressController.text}\n${_postalCodeController.text} ${_cityController.text}".trim(),
|
||||
multiLine: true,
|
||||
fieldHeight: 80,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper pour afficher un champ en lecture seule avec le style de l'ancien récap
|
||||
Widget _buildDisplayFieldValue(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value, {
|
||||
bool multiLine = false,
|
||||
double fieldHeight = 50.0,
|
||||
double labelFontSize = 18.0,
|
||||
double valueFontSize = 18.0, // Taille du texte dans le champ
|
||||
}) {
|
||||
const FontWeight labelFontWeight = FontWeight.w600;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: labelFontSize,
|
||||
fontWeight: labelFontWeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: multiLine ? null : fieldHeight,
|
||||
constraints: multiLine ? const BoxConstraints(minHeight: 50.0) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/bg_beige.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: valueFontSize),
|
||||
maxLines: multiLine ? null : 1,
|
||||
overflow: multiLine ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout MOBILE : tous les champs empilés verticalement
|
||||
Widget _buildMobileFields(BuildContext context, DisplayConfig config) {
|
||||
// Mode Readonly Mobile : Layout compact et groupé
|
||||
if (config.isReadonly) {
|
||||
// NOTE: FormFieldWrapper ajoute déjà un padding vertical (8px mobile),
|
||||
// donc on n'ajoute pas de SizedBox supplémentaire ici pour éviter un double espacement.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildField(config: config, label: 'Nom', controller: _lastNameController),
|
||||
_buildField(config: config, label: 'Prénom', controller: _firstNameController),
|
||||
_buildField(config: config, label: 'Téléphone', controller: _phoneController),
|
||||
_buildField(config: config, label: 'Email', controller: _emailController),
|
||||
// Adresse complète en un seul bloc multiligne
|
||||
FormFieldWrapper(
|
||||
config: config,
|
||||
label: 'Adresse',
|
||||
value: "${_addressController.text}\n${_postalCodeController.text} ${_cityController.text}".trim(),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Mode Editable Mobile : Layout standard avec champs séparés
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Nom
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Nom',
|
||||
controller: _lastNameController,
|
||||
hint: 'Votre nom de famille',
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prénom
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Prénom',
|
||||
controller: _firstNameController,
|
||||
hint: 'Votre prénom',
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Téléphone
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Téléphone',
|
||||
controller: _phoneController,
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Email
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
hint: 'Votre adresse e-mail',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: _fieldsEnabled,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Adresse
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Adresse (N° et Rue)',
|
||||
controller: _addressController,
|
||||
hint: 'Numéro et nom de votre rue',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Code Postal
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Code Postal',
|
||||
controller: _postalCodeController,
|
||||
hint: 'Code postal',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Ville
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville',
|
||||
controller: _cityController,
|
||||
hint: 'Votre ville',
|
||||
enabled: _fieldsEnabled && !_sameAddress,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un champ individuel (éditable ou readonly)
|
||||
Widget _buildField({
|
||||
required DisplayConfig config,
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
TextInputType? keyboardType,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
if (config.isReadonly) {
|
||||
// Mode readonly : utiliser FormFieldWrapper
|
||||
return FormFieldWrapper(
|
||||
config: config,
|
||||
label: label,
|
||||
value: controller.text,
|
||||
);
|
||||
} else {
|
||||
// Mode éditable : style adapté mobile/desktop
|
||||
return CustomAppTextField(
|
||||
controller: controller,
|
||||
labelText: label,
|
||||
hintText: hint ?? label,
|
||||
style: CustomAppTextFieldStyle.beige,
|
||||
fieldWidth: double.infinity,
|
||||
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
switch (widget.cardColor) {
|
||||
case CardColorHorizontal.blue:
|
||||
return CardColorVertical.blue.path;
|
||||
case CardColorHorizontal.green:
|
||||
return CardColorVertical.green.path;
|
||||
case CardColorHorizontal.lavender:
|
||||
return CardColorVertical.lavender.path;
|
||||
case CardColorHorizontal.lime:
|
||||
return CardColorVertical.lime.path;
|
||||
case CardColorHorizontal.peach:
|
||||
return CardColorVertical.peach.path;
|
||||
case CardColorHorizontal.pink:
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'custom_decorated_text_field.dart';
|
||||
import 'app_custom_checkbox.dart';
|
||||
import 'custom_navigation_button.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import '../config/display_config.dart';
|
||||
|
||||
/// Widget générique pour le formulaire de présentation avec texte libre + CGU
|
||||
/// Supporte mode éditable et readonly, responsive mobile/desktop
|
||||
class PresentationFormScreen extends StatefulWidget {
|
||||
final DisplayMode mode;
|
||||
final String stepText; // Ex: "Étape 3/4" ou "Étape 4/5"
|
||||
final String title; // Ex: "Présentation et Conditions" ou "Motivation de votre demande"
|
||||
final CardColorHorizontal cardColor;
|
||||
final String textFieldHint;
|
||||
final String initialText;
|
||||
final bool initialCguAccepted;
|
||||
final String previousRoute;
|
||||
final Function(String text, bool cguAccepted) onSubmit;
|
||||
|
||||
final bool embedContentOnly;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const PresentationFormScreen({
|
||||
super.key,
|
||||
this.mode = DisplayMode.editable,
|
||||
required this.stepText,
|
||||
required this.title,
|
||||
required this.cardColor,
|
||||
required this.textFieldHint,
|
||||
required this.initialText,
|
||||
required this.initialCguAccepted,
|
||||
required this.previousRoute,
|
||||
required this.onSubmit,
|
||||
this.embedContentOnly = false,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PresentationFormScreen> createState() => _PresentationFormScreenState();
|
||||
}
|
||||
|
||||
class _PresentationFormScreenState extends State<PresentationFormScreen> {
|
||||
late TextEditingController _textController;
|
||||
late bool _cguAccepted;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = TextEditingController(text: widget.initialText);
|
||||
_cguAccepted = widget.initialCguAccepted;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (!_cguAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vous devez accepter les CGU pour continuer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
widget.onSubmit(_textController.text, _cguAccepted);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final config = DisplayConfig.fromContext(context, mode: widget.mode);
|
||||
|
||||
if (widget.embedContentOnly) {
|
||||
return _buildCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
config.isMobile
|
||||
? _buildMobileLayout(context, config, screenSize)
|
||||
: _buildDesktopLayout(context, config, screenSize),
|
||||
// Chevrons desktop uniquement
|
||||
if (!config.isMobile) ...[
|
||||
// Chevron Gauche (Retour)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
tooltip: 'Retour',
|
||||
),
|
||||
),
|
||||
// Chevron Droit (Suivant)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _cguAccepted ? _handleSubmit : null,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout MOBILE : Plein écran sans scroll global
|
||||
Widget _buildMobileLayout(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header fixe
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
widget.stepText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Carte qui prend tout l'espace restant
|
||||
Expanded(
|
||||
child: _buildCard(context, config, screenSize),
|
||||
),
|
||||
// Boutons en bas
|
||||
const SizedBox(height: 20),
|
||||
_buildMobileButtons(context, config, screenSize),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout DESKTOP : Avec scroll
|
||||
Widget _buildDesktopLayout(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 50.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.stepText,
|
||||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
_buildCard(context, config, screenSize),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrapper pour la carte (Mobile ou Desktop)
|
||||
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal (2:1)
|
||||
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
|
||||
return _buildReadonlyDesktopCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical (1:2)
|
||||
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: _buildMobileReadonlyCard(context, config, screenSize),
|
||||
);
|
||||
}
|
||||
|
||||
final Widget cardContent = config.isMobile
|
||||
? _buildMobileCard(context, config, screenSize)
|
||||
: _buildDesktopCard(context, config, screenSize);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (widget.embedContentOnly)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 18 : 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
cardContent,
|
||||
],
|
||||
)
|
||||
else
|
||||
cardContent,
|
||||
|
||||
if (config.isReadonly && widget.onEdit != null)
|
||||
Positioned(
|
||||
top: widget.embedContentOnly ? 50 : 10,
|
||||
right: 10,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||||
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// Pas de height fixe, s'adapte au contenu
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(_getVerticalCardAsset()),
|
||||
fit: BoxFit.fill, // Fill pour que l'image s'étire selon la hauteur du contenu
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min, // S'adapte au contenu
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
const SizedBox(width: 28),
|
||||
],
|
||||
),
|
||||
|
||||
// Contenu aligné en haut (Texte scrollable + Checkbox)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Champ texte scrollable
|
||||
// On utilise ConstrainedBox pour limiter la hauteur max
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 300), // Max height pour éviter une carte infinie
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _textController,
|
||||
hintText: widget.textFieldHint,
|
||||
fieldHeight: null, // Flexible
|
||||
maxLines: 100,
|
||||
expandDynamically: true, // Scrollable
|
||||
fontSize: 14.0,
|
||||
readOnly: config.isReadonly,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Checkbox
|
||||
Transform.scale(
|
||||
scale: 0.85,
|
||||
child: AppCustomCheckbox(
|
||||
label: 'J\'accepte les CGU et la\nPolitique de confidentialité',
|
||||
value: _cguAccepted,
|
||||
onChanged: (v) {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly desktop avec AspectRatio 2:1 (format de l'ancien récapitulatif)
|
||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Largeur de la carte : 50% de l'écran
|
||||
final cardWidth = screenSize.width / 2.0;
|
||||
|
||||
return SizedBox(
|
||||
width: cardWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(widget.cardColor.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Texte de motivation
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _textController,
|
||||
hintText: '',
|
||||
fieldHeight: double.infinity, // Remplit l'espace disponible
|
||||
maxLines: 10,
|
||||
expandDynamically: false, // Fixe pour le readonly
|
||||
fontSize: 18.0,
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// CGU
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte les Conditions Générales\nd\'Utilisation et la Politique de confidentialité',
|
||||
value: _cguAccepted,
|
||||
onChanged: (v) {}, // Readonly
|
||||
checkboxSize: 22.0,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte DESKTOP : Format horizontal 2:1
|
||||
Widget _buildDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
final cardWidth = screenSize.width * 0.6;
|
||||
final double imageAspectRatio = 2.0;
|
||||
final cardHeight = cardWidth / imageAspectRatio;
|
||||
|
||||
return Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(widget.cardColor.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomDecoratedTextField(
|
||||
controller: _textController,
|
||||
hintText: widget.textFieldHint,
|
||||
fieldHeight: cardHeight * 0.6,
|
||||
maxLines: 10,
|
||||
expandDynamically: true,
|
||||
fontSize: 18.0,
|
||||
readOnly: config.isReadonly,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte les Conditions Générales\nd\'Utilisation et la Politique de confidentialité',
|
||||
value: _cguAccepted,
|
||||
onChanged: config.isReadonly ? (v) {} : (value) => setState(() => _cguAccepted = value ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte MOBILE : Prend tout l'espace disponible
|
||||
Widget _buildMobileCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Le contenu du champ texte
|
||||
Widget textFieldContent = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// En mode embed (récap), constraints.maxHeight peut être infini, donc on fixe une hauteur par défaut
|
||||
// En mode standalone, on utilise la hauteur disponible
|
||||
double height = constraints.maxHeight;
|
||||
if (height.isInfinite) height = 200.0;
|
||||
|
||||
return CustomDecoratedTextField(
|
||||
controller: _textController,
|
||||
hintText: widget.textFieldHint,
|
||||
fieldHeight: height,
|
||||
maxLines: 100,
|
||||
expandDynamically: false,
|
||||
fontSize: 14.0,
|
||||
readOnly: config.isReadonly,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(_getVerticalCardAsset()),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Champ de texte
|
||||
if (widget.embedContentOnly)
|
||||
// En mode récapitulatif, on donne une hauteur fixe pour éviter l'erreur d'Expanded
|
||||
SizedBox(height: 200, child: textFieldContent)
|
||||
else
|
||||
// En mode écran complet, on prend tout l'espace restant
|
||||
Expanded(child: textFieldContent),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// Checkbox en bas
|
||||
Transform.scale(
|
||||
scale: 0.85,
|
||||
child: AppCustomCheckbox(
|
||||
label: 'J\'accepte les CGU et la\nPolitique de confidentialité',
|
||||
value: _cguAccepted,
|
||||
onChanged: config.isReadonly ? (v) {} : (value) => setState(() => _cguAccepted = value ?? false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Boutons mobile
|
||||
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: screenSize.width * 0.05,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Suivant',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: _handleSubmit,
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
switch (widget.cardColor) {
|
||||
case CardColorHorizontal.blue:
|
||||
return CardColorVertical.blue.path;
|
||||
case CardColorHorizontal.green:
|
||||
return CardColorVertical.green.path;
|
||||
case CardColorHorizontal.lavender:
|
||||
return CardColorVertical.lavender.path;
|
||||
case CardColorHorizontal.lime:
|
||||
return CardColorVertical.lime.path;
|
||||
case CardColorHorizontal.peach:
|
||||
return CardColorVertical.peach.path;
|
||||
case CardColorHorizontal.pink:
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
import '../models/card_assets.dart';
|
||||
import '../config/display_config.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'app_custom_checkbox.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import 'custom_navigation_button.dart';
|
||||
|
||||
/// Données pour le formulaire d'informations professionnelles
|
||||
class ProfessionalInfoData {
|
||||
final String? photoPath;
|
||||
final File? photoFile;
|
||||
final bool photoConsent;
|
||||
final DateTime? dateOfBirth;
|
||||
final String birthCity;
|
||||
final String birthCountry;
|
||||
final String nir;
|
||||
final String agrementNumber;
|
||||
final int? capacity;
|
||||
|
||||
ProfessionalInfoData({
|
||||
this.photoPath,
|
||||
this.photoFile,
|
||||
this.photoConsent = false,
|
||||
this.dateOfBirth,
|
||||
this.birthCity = '',
|
||||
this.birthCountry = '',
|
||||
this.nir = '',
|
||||
this.agrementNumber = '',
|
||||
this.capacity,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget générique pour le formulaire d'informations professionnelles
|
||||
/// Utilisé pour l'inscription des Assistantes Maternelles
|
||||
/// Supporte mode éditable et readonly, responsive mobile/desktop
|
||||
class ProfessionalInfoFormScreen extends StatefulWidget {
|
||||
final DisplayMode mode;
|
||||
final String stepText;
|
||||
final String title;
|
||||
final CardColorHorizontal cardColor;
|
||||
final ProfessionalInfoData? initialData;
|
||||
final String previousRoute;
|
||||
final Function(ProfessionalInfoData) onSubmit;
|
||||
final Future<void> Function()? onPickPhoto;
|
||||
final bool embedContentOnly;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const ProfessionalInfoFormScreen({
|
||||
super.key,
|
||||
this.mode = DisplayMode.editable,
|
||||
required this.stepText,
|
||||
required this.title,
|
||||
required this.cardColor,
|
||||
this.initialData,
|
||||
required this.previousRoute,
|
||||
required this.onSubmit,
|
||||
this.onPickPhoto,
|
||||
this.embedContentOnly = false,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProfessionalInfoFormScreen> createState() => _ProfessionalInfoFormScreenState();
|
||||
}
|
||||
|
||||
class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _dateOfBirthController = TextEditingController();
|
||||
final _birthCityController = TextEditingController();
|
||||
final _birthCountryController = TextEditingController();
|
||||
final _nirController = TextEditingController();
|
||||
final _agrementController = TextEditingController();
|
||||
final _capacityController = TextEditingController();
|
||||
|
||||
DateTime? _selectedDate;
|
||||
String? _photoPathFramework;
|
||||
File? _photoFile;
|
||||
bool _photoConsent = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final data = widget.initialData;
|
||||
if (data != null) {
|
||||
_selectedDate = data.dateOfBirth;
|
||||
_dateOfBirthController.text = data.dateOfBirth != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
|
||||
: '';
|
||||
_birthCityController.text = data.birthCity;
|
||||
_birthCountryController.text = data.birthCountry;
|
||||
_nirController.text = data.nir;
|
||||
_agrementController.text = data.agrementNumber;
|
||||
_capacityController.text = data.capacity?.toString() ?? '';
|
||||
_photoPathFramework = data.photoPath;
|
||||
_photoFile = data.photoFile;
|
||||
_photoConsent = data.photoConsent;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dateOfBirthController.dispose();
|
||||
_birthCityController.dispose();
|
||||
_birthCountryController.dispose();
|
||||
_nirController.dispose();
|
||||
_agrementController.dispose();
|
||||
_capacityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
|
||||
firstDate: DateTime(1920, 1),
|
||||
lastDate: DateTime.now().subtract(const Duration(days: 365 * 18)),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
_dateOfBirthController.text = DateFormat('dd/MM/yyyy').format(picked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
if (widget.onPickPhoto != null) {
|
||||
await widget.onPickPhoto!();
|
||||
} else {
|
||||
// Comportement par défaut : utiliser un asset de test
|
||||
setState(() {
|
||||
_photoPathFramework = 'assets/images/icon_assmat.png';
|
||||
_photoFile = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (_photoPathFramework != null && !_photoConsent) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = ProfessionalInfoData(
|
||||
photoPath: _photoPathFramework,
|
||||
photoFile: _photoFile,
|
||||
photoConsent: _photoConsent,
|
||||
dateOfBirth: _selectedDate,
|
||||
birthCity: _birthCityController.text,
|
||||
birthCountry: _birthCountryController.text,
|
||||
nir: _nirController.text,
|
||||
agrementNumber: _agrementController.text,
|
||||
capacity: int.tryParse(_capacityController.text),
|
||||
);
|
||||
|
||||
widget.onSubmit(data);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final config = DisplayConfig.fromContext(context, mode: widget.mode);
|
||||
|
||||
if (widget.embedContentOnly) {
|
||||
return _buildCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.stepText,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 13 : 16,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
SizedBox(height: config.isMobile ? 6 : 10),
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 18 : 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: config.isMobile ? 16 : 30),
|
||||
_buildCard(context, config, screenSize),
|
||||
|
||||
// Boutons mobile sous la carte
|
||||
if (config.isMobile) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildMobileButtons(context, config, screenSize),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chevrons desktop uniquement
|
||||
if (!config.isMobile) ...[
|
||||
// Chevron Gauche (Retour)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
left: 40,
|
||||
child: IconButton(
|
||||
icon: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.rotationY(math.pi),
|
||||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
tooltip: 'Précédent',
|
||||
),
|
||||
),
|
||||
// Chevron Droit (Suivant)
|
||||
Positioned(
|
||||
top: screenSize.height / 2 - 20,
|
||||
right: 40,
|
||||
child: IconButton(
|
||||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||||
onPressed: _submitForm,
|
||||
tooltip: 'Suivant',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal
|
||||
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
|
||||
return _buildReadonlyDesktopCard(context, config, screenSize);
|
||||
}
|
||||
|
||||
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical
|
||||
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||||
child: _buildMobileReadonlyCard(context, config, screenSize),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
|
||||
horizontal: config.isMobile ? 24 : 50,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
config.isMobile
|
||||
? _getVerticalCardAsset()
|
||||
: widget.cardColor.path
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.embedContentOnly) ...[
|
||||
Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: config.isMobile ? 18 : 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
config.isMobile
|
||||
? _buildMobileFields(context, config)
|
||||
: _buildDesktopFields(context, config),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (config.isReadonly && widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||||
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// Pas de height fixe
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(_getVerticalCardAsset()),
|
||||
fit: BoxFit.fill, // Fill pour s'adapter
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
const SizedBox(width: 28),
|
||||
],
|
||||
),
|
||||
|
||||
// Contenu aligné en haut
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0),
|
||||
child: _buildMobileFields(context, config),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.onEdit != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte en mode readonly desktop avec AspectRatio 2:1
|
||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
final cardWidth = screenSize.width / 2.0;
|
||||
|
||||
return SizedBox(
|
||||
width: cardWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(widget.cardColor.path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre + Edit Button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.onEdit != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||||
onPressed: widget.onEdit,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Contenu
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// PHOTO (1/3)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _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: 10),
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte l\'utilisation\nde ma photo.',
|
||||
value: _photoConsent,
|
||||
onChanged: (v) {}, // Readonly
|
||||
checkboxSize: 22.0,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// CHAMPS (2/3) - Layout optimisé compact
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 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(
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Ligne 3 : Agrément + Capacité
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: _buildReadonlyField('N° Agrément', _agrementController.text)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 2, child: _buildReadonlyField('Capacité', _capacityController.text)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper pour champ Readonly style "Beige"
|
||||
Widget _buildReadonlyField(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 45.0, // Hauteur réduite pour compacter
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/bg_beige.png'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: 16.0),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout DESKTOP : Photo à gauche, champs à droite
|
||||
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
|
||||
final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Photo + Checkbox à gauche
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: _buildPhotoSection(context, config),
|
||||
),
|
||||
const SizedBox(width: 30),
|
||||
// Champs à droite
|
||||
Expanded(
|
||||
child: Column(
|
||||
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(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'N° Sécurité Sociale (NIR)',
|
||||
controller: _nirController,
|
||||
hint: 'Votre NIR à 13 chiffres',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'NIR requis';
|
||||
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
|
||||
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'N° d\'agrément',
|
||||
controller: _agrementController,
|
||||
hint: 'Votre numéro d\'agrément',
|
||||
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Capacité d\'accueil',
|
||||
controller: _capacityController,
|
||||
hint: 'Ex: 3',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Capacité requise';
|
||||
final n = int.tryParse(v);
|
||||
if (n == null || n <= 0) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout MOBILE : Tout empilé verticalement
|
||||
Widget _buildMobileFields(BuildContext context, DisplayConfig config) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Photo + Checkbox en premier
|
||||
_buildPhotoSection(context, config),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville de naissance',
|
||||
controller: _birthCityController,
|
||||
hint: 'Votre ville de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
hint: 'Votre pays de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'N° Sécurité Sociale (NIR)',
|
||||
controller: _nirController,
|
||||
hint: 'Votre NIR à 13 chiffres',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'NIR requis';
|
||||
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
|
||||
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'N° d\'agrément',
|
||||
controller: _agrementController,
|
||||
hint: 'Votre numéro d\'agrément',
|
||||
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Capacité d\'accueil',
|
||||
controller: _capacityController,
|
||||
hint: 'Ex: 3',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Capacité requise';
|
||||
final n = int.tryParse(v);
|
||||
if (n == null || n <= 0) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Section photo + checkbox
|
||||
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
|
||||
final Color baseCardColorForShadow = Colors.green.shade300;
|
||||
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
|
||||
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
|
||||
|
||||
ImageProvider? currentImageProvider;
|
||||
if (_photoFile != null) {
|
||||
currentImageProvider = FileImage(_photoFile!);
|
||||
} else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
|
||||
currentImageProvider = AssetImage(_photoPathFramework!);
|
||||
}
|
||||
|
||||
final photoSize = config.isMobile ? 200.0 : 270.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: _pickPhoto,
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
child: SizedBox(
|
||||
height: photoSize,
|
||||
width: photoSize,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: currentImageProvider != null
|
||||
? DecorationImage(image: currentImageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
child: currentImageProvider == null
|
||||
? Image.asset('assets/images/photo.png', fit: BoxFit.contain)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte l\'utilisation\nde ma photo.',
|
||||
value: _photoConsent,
|
||||
onChanged: (val) => setState(() => _photoConsent = val ?? false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un champ individuel
|
||||
Widget _buildField({
|
||||
required DisplayConfig config,
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
TextInputType? keyboardType,
|
||||
bool readOnly = false,
|
||||
VoidCallback? onTap,
|
||||
IconData? suffixIcon,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
if (config.isReadonly) {
|
||||
return FormFieldWrapper(
|
||||
config: config,
|
||||
label: label,
|
||||
value: controller.text,
|
||||
);
|
||||
} else {
|
||||
return CustomAppTextField(
|
||||
controller: controller,
|
||||
labelText: label,
|
||||
hintText: hint ?? label,
|
||||
fieldWidth: double.infinity,
|
||||
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
suffixIcon: suffixIcon,
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boutons mobile
|
||||
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: screenSize.width * 0.05,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Précédent',
|
||||
style: NavigationButtonStyle.purple,
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(widget.previousRoute);
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: HoverReliefWidget(
|
||||
child: CustomNavigationButton(
|
||||
text: 'Suivant',
|
||||
style: NavigationButtonStyle.green,
|
||||
onPressed: _submitForm,
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
switch (widget.cardColor) {
|
||||
case CardColorHorizontal.blue:
|
||||
return CardColorVertical.blue.path;
|
||||
case CardColorHorizontal.green:
|
||||
return CardColorVertical.green.path;
|
||||
case CardColorHorizontal.lavender:
|
||||
return CardColorVertical.lavender.path;
|
||||
case CardColorHorizontal.lime:
|
||||
return CardColorVertical.lime.path;
|
||||
case CardColorHorizontal.peach:
|
||||
return CardColorVertical.peach.path;
|
||||
case CardColorHorizontal.pink:
|
||||
return CardColorVertical.pink.path;
|
||||
case CardColorHorizontal.red:
|
||||
return CardColorVertical.red.path;
|
||||
}
|
||||
}
|
||||
}
|
||||