Compare commits

..
Author SHA1 Message Date
jmartinandCursor 7f8104e7e4 fix(auth): correctif login et parsing user (version stable)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 15:54:24 +01:00
jmartinandCursor c4d93ee458 Merge develop into master (squash)
- Ticket #92: endpoint GET /gestionnaires, dashboard admin données réelles
- Seed données de test, script reset-and-seed-db.sh
- Modèles frontend (Parent, AM), user_service, widgets admin

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:55:38 +01:00
jmartin 1fca0cf132 Merge branch 'master' of https://git.ptits-pas.fr/jmartin/petitspas 2026-02-16 16:22:16 +01:00
jmartinandCursor b16dd4b55c merge: resolution conflits develop -> master (ticket #14)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:22:04 +01:00
jmartinandCursor 8682421453 Merge develop into master (squash)
- feat(#90): API Inscription AM - POST /auth/register/am
- Suppression legacy register/parent/legacy
- BDD assistantes_maternelles alignée entité
- Script test register AM

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:19:23 +01:00
jmartinandCursor 31bd8c3175 fix(#90): BDD assistantes_maternelles alignée entité + script test curl
- BDD.sql: ville_residence, annee_experience, specialite, date_agrement nullable
- scripts/test-register-am.sh pour tester POST /auth/register/am

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:18:06 +01:00
jmartinandCursor c94f2cf0d5 feat(#90): API Inscription AM - POST /auth/register/am
- DTO RegisterAMCompletDto (identité, photo, infos pro, CGU)
- Endpoint POST /auth/register/am + inscrireAMComplet() (transaction User + AssistanteMaternelle)
- Photo base64, token création MDP, consentement photo
- Suppression legacy: route register/parent/legacy, registerParent(), RegisterParentDto
- Frontend: ApiConfig.registerAM pour ticket #91

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:05:23 +01:00
jmartinandCursor dfe7daed14 Merge squash develop into master (incl. #14 première config setup/complete)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:20:15 +01:00
jmartin 111935e451 Merge branch 'feature/14-premiere-connexion-config' into develop (#14) 2026-02-15 23:20:00 +01:00
jmartinandCursor ae3292a7fc fix(backend): setup/complete accepte userId null pour éviter erreur UUID (#14)
- completeSetup: userId = req.user?.id ?? null (plus de fallback 'system')
- markSetupCompleted(userId: string | null), set(..., userId ?? undefined)
- Corrige 'invalid input syntax for type uuid: "system"' au clic Sauvegarder

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:19:18 +01:00
jmartinandCursor 8e8c6d79b1 feat(#14): finalisation redirection et nettoyage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:08:02 +01:00
jmartinandCursor 6752dc97b4 feat(#14): redirection première connexion config
- Redirection vers /login après première config réussie
- Gestion défensive des réponses API (200/201, bool/string)
- Force l'onglet Paramètres si setup non terminé

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:02:12 +01:00
jmartinandCursor 31857ec891 docs(#14): note back config/setup + frontend parsing erreurs
- docs/14_NOTE-BACKEND-CONFIG-SETUP.md : modifs à faire côté back (UUID system)
- configuration_service : parsing défensif des réponses d'erreur (évite JSNull)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:10:04 +01:00
jmartinandCursor ca7ef862da feat(admin): première connexion → panneau Paramètres, reste grisé jusqu'à Sauvegarder (#14)
- Au chargement admin: appel getSetupStatus(), si non terminé → onglet Paramètres par défaut
- Onglet Gestion des utilisateurs grisé et inaccessible tant que setup non complété
- Sauvegarder: updateBulk + completeSetup + déblocage des panneaux
- Tester SMTP: saveBulkOnly puis test (sans completeSetup, panneaux restent verrouillés)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:54:47 +01:00
jmartinandCursor 11aa66feff Merge squash develop into master (incl. #89 log API requests debug)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:26:06 +01:00
jmartin 358eefdab3 Merge branch 'feature/89-log-api-requests' into develop (#89) 2026-02-13 15:25:56 +01:00
jmartinandCursor d23f3c9f4f feat(admin): panneau Paramètres - sauvegarde config + test SMTP (#15)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:23:00 +01:00
jmartinandCursor 1834eb8c79 feat(admin): panneau Paramètres - sauvegarde config + test SMTP
- Onglet Paramètres dans l'admin avec 3 sections (Email, Personnalisation, Avancé)
- Service ConfigurationService (GET config, PATCH bulk, POST test-smtp)
- Bouton Sauvegarder et bouton Tester SMTP (sauvegarde avant test)
- Endpoints api_config pour configuration

Closes #15

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 11:16:37 +01:00
jmartinandCursor 0386785f81 feat(backend): log des appels API en mode debug (#89)
- Ajout LogRequestInterceptor (méthode, URL, query, body)
- Activé via LOG_API_REQUESTS=true
- Masquage des champs sensibles (password, smtp_password, token...)
- Enregistrement global dans main.ts, doc dans .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 11:15:41 +01:00
jmartinandCursor c43f55bed6 Merge origin/develop (conflit 23_LISTE-TICKETS résolu, garde numéros = Gitea)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:20:59 +01:00
jmartinandCursor 0c48a5c06f Merge branch 'develop' (docs concept v1.3, conflit résolu)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:20:32 +01:00
jmartinandCursor be8b1f23ed docs: concept v1.3 config (panneau Paramètres 3 sections, numéros = Gitea)
- 21_CONFIGURATION-SYSTEME: workflow sans /admin/setup, 3 sections, panneau unique
- 23_LISTE-TICKETS: numéros de section = numéros Gitea, tickets #14/#15 alignés
- 24_DECISIONS-PROJET: config initiale = panneau + navigation bloquée
- BRIEFING-FRONTEND: tickets #12/#13 remplacés par panneau Paramètres
- Suppression login_screen.dart.bak

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:19:35 +01:00
jmartinandCursor 18b270eaa3 Merge origin/develop: résolution conflits doc tickets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:55:16 +01:00
jmartinandCursor 68e4f54814 Merge develop (squash): correctifs modale MDP (champs lavande/jaune), doc tickets 84/85
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:53:58 +01:00
jmartinandCursor 6794190916 chore(login): retrait du lien Test modale MDP
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:52:51 +01:00
jmartinandCursor 790761d576 fix(modale): champs MDP actuel lavande, nouveau et confirmation jaune
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:52:25 +01:00
jmartinandCursor 930097f87d Merge develop: fix auth connexion admin (#84)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 22:37:48 +01:00
jmartinandCursor 18af5c9034 fix(auth): connexion admin - token snake_case, routes GoRouter, profil (Closes #84)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 22:36:52 +01:00
jmartinandCursor 10bf2553e7 fix(ui): renforcer ombre des boutons Parents/AM sur mobile (écran choix)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:15:10 +01:00
jmartinandCursor 678f4219b5 docs: ticket #82 fermé (écran Login mobile)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:56:07 +01:00
jmartinandCursor 5295e8ec72 Merge develop (squash): login mobile, formulaire sous slogan par ratio
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:54:16 +01:00
jmartinandCursor 480f4a9396 fix(login): position formulaire sous slogan par ratio image (river_logo_mobile)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:53:37 +01:00
jmartinandCursor 6bf0932da8 docs: Index, doc API Gitea et script fermeture issue
- docs/00_INDEX.md : entrée pour 26_GITEA-API
- docs/26_GITEA-API.md : procédure API Gitea (auth, issues, PR, branches, dépannage)
- scripts/gitea-close-issue.sh : fermer une issue via l'API (GITEA_TOKEN / .gitea-token)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:39:47 +01:00
jmartinandCursor 2f1740b35f docs: Ajouter ticket #83 RegisterChoiceScreen Mobile (terminé)
Mise à jour de la liste des tickets pour documenter le ticket #83 complété :
- Adaptation responsive RegisterChoiceScreen (mobile/desktop)
- Extraction ChoiceCardWidget réutilisable
- Bouton Précédent stylisé avec CustomNavigationButton
- Tailles icônes augmentées (140px/170px)

Total: 65 tickets (~184h)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:37:04 +01:00
jmartinandCursor fd97e68dd9 feat: Adapter RegisterChoiceScreen pour mobile (Closes #83)
- Implémentation responsive avec LayoutBuilder pour détecter mobile/desktop
- Mode mobile : titre au-dessus, carte pleine largeur avec ratio 2/3, boutons verticaux
- Mode desktop : chevron en haut à gauche, layout texte/carte côte à côte
- Extraction de la logique de carte dans ChoiceCardWidget réutilisable
- Bouton "Précédent" stylisé avec CustomNavigationButton et HoverReliefWidget
- Tailles d'icônes augmentées (140px mobile, 170px desktop)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:34:50 +01:00
jmartinandCursor 813fdb8449 fix(#83): Corriger le style du bouton Précédent et ajuster les tailles d'icônes
Utilise CustomNavigationButton avec HoverReliefWidget pour le bouton Précédent en mode mobile, assurant la cohérence visuelle avec les autres écrans. Augmente également la taille des icônes de choix (140px mobile, 170px desktop).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:33:48 +01:00
jmartinandCursor 155c6ca4d5 chore(deps): Update pubspec.lock
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 11:44:47 +01:00
jmartinandCursor 030ef81038 feat(frontend): Adapt RegisterChoiceScreen for mobile (Closes #83)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:43:17 +01:00
jmartinandCursor 0d88597bb6 test
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:42:09 +01:00
jmartinandCursor 9b007fe490 docs: Mark tickets #38, #39, #40, #41, #42 as completed (Closes #38, Closes #39, Closes #40, Closes #41, Closes #42)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:15:15 +01:00
jmartinandCursor 7ecb99963c docs: Update ticket list with #81 status (Closes #81)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:10:47 +01:00
jmartinandCursor 39814c76b1 docs: Update ticket list and cleanup obsolete files (Closes #79, Closes #80)
- Delete obsolete nanny_registration_data.dart and nanny_register_confirmation_screen.dart
- Update 23_LISTE-TICKETS.md with #79 and #80 status

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:09:29 +01:00
jmartin b956f94ad2 Merge branch 'develop' 2026-02-07 14:52:47 +01:00
jmartinandCursor b18d5c8a9e feat(frontend): Refonte infrastructure formulaires multi-modes
- Support des modes Desktop/Mobile et Édition/Lecture seule
- Refactoring des widgets de formulaire (PersonalInfo, ProfessionalInfo, Presentation, ChildCard)
- Mise à jour des écrans de récapitulatif (ParentStep5, AmStep4)
- Ajout de navigation (Précédent/Soumettre) sur mobile

Closes #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:51:33 +01:00
jmartinandCursor 6ad88cbbc6 docs: Update ticket list with #78 (Multi-mode forms refactoring)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:46:34 +01:00
jmartinandCursor dfe91ed772 Add navigation buttons to mobile recap screens and fix child card width
- Add 'Previous' and 'Submit' buttons to mobile recap screens (Parent & AM)
- Fix imports for navigation buttons and widgets
- Adjust ChildCardWidget width to fill available space on mobile editing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:44:17 +01:00
jmartinandCursor 08612c455d Fix recap screens layout (desktop/mobile) and widget styles
- Restore horizontal 2:1 layout for desktop readonly cards
- Implement adaptive height for mobile readonly cards
- Fix spacing and margins on mobile recap screens
- Update field styles to use beige background
- Adjust ChildCardWidget width for mobile editing
- Fix compilation errors and duplicate methods

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:29:11 +01:00
jmartinandCursor 6452706680 fix(#78): Ajustements UI ChildCardWidget et ParentStep3
- Réduction de la taille des polices et des champs dans la carte enfant (Mobile/Desktop) pour éviter l'overflow.
- Restauration de la taille du bouton "+" en mode Desktop (100px).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 14:33:04 +01:00
jmartinandCursor eea94769bf feat(#78): Migrer ParentRegisterStep3Screen (Enfants) vers infrastructure multi-modes
Adaptation responsive du formulaire "Informations Enfants" (Parent Step 3) :
- Desktop : Conservation du layout horizontal avec scroll et effets de fondu
- Mobile : Layout vertical avec cartes empilées
  - Header fixe
  - Bouton "+" carré (50px) centré à la fin de la liste
  - Boutons navigation intégrés au scroll
  - Cartes enfants adaptées (scale 0.9, polices réduites)
- Mise à jour DisplayConfig (mode optionnel par défaut)
- Mise à jour AppCustomCheckbox (paramètre fontSize)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 11:43:26 +01:00
jmartinandCursor bdecbc2c1d feat(#78): Migrer PresentationFormScreen vers infrastructure multi-modes
Adaptation responsive du formulaire de présentation (AM Step 3) :
- Desktop : Layout horizontal avec scroll global (format 2:1)
- Mobile : Layout plein écran sans scroll global
  - Header fixe (titre + étape)
  - Carte occupe tout l'espace vertical disponible
  - Seul le champ texte interne est scrollable
  - Boutons fixes en bas
- Checkbox CGU adaptée (texte raccourci + scale 0.85 en mobile)
- Chevrons uniquement en mode desktop

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 11:19:43 +01:00
jmartinandCursor f8bd911c02 feat(#78): Migrer ProfessionalInfoFormScreen vers infrastructure multi-modes
Adaptation responsive du formulaire d'informations professionnelles (AM Step 2) :
- Desktop : photo gauche (270px) + champs droite
- Mobile : layout vertical avec photo 200px + champs empilés
- Boutons CustomNavigationButton (violet/vert) sous la carte en mobile
- Chevrons uniquement en mode desktop
- Espacement adapté (12px mobile vs 32px desktop)
- Tailles de police adaptées

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 11:12:42 +01:00
jmartinandCursor b79f8c7e64 refactor(#78): Renommer assets images pour usage générique
Renommage des assets pour permettre leur utilisation
aussi bien pour les boutons que pour les champs :

**Images renommées :**
- input_field_bg.png → bg_beige.png
- input_field_jaune.png → bg_yellow.png
- input_field_lavande.png → bg_lavender.png
- btn_green.png → bg_green.png

**Fichiers mis à jour (8) :**
- custom_app_text_field.dart (champs de formulaire)
- custom_navigation_button.dart (nouveau widget boutons)
- base_form_screen.dart (structure de page)
- login_screen.dart
- change_password_dialog.dart
- am_register_step4_screen.dart
- parent_register_step5_screen.dart
- summary_screen.dart

**Avantages :**
✅ Noms génériques et cohérents
✅ Réutilisabilité boutons ET champs
✅ Maintenance facilitée

Référence: #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 11:01:54 +01:00
jmartinandCursor a57993a90f feat(#78): Finaliser layout mobile responsive de PersonalInfoFormScreen
Layout mobile complètement repensé avec séparation desktop/mobile :

**Layout Desktop (_buildDesktopFields) :**
- Champs par paires horizontales (Row avec Expanded)
- Code Postal + Ville avec ratio flex 2:5
- Espacement 32px entre lignes
- Taille police : 22px labels, 20px input

**Layout Mobile (_buildMobileFields) :**
- Tous les champs empilés verticalement (Column pure)
- Chaque champ prend toute la largeur
- Espacement 12px entre champs (compact)
- Taille police : 15px labels, 14px input
- Hauteur champs réduite : 45px

**Nouveau widget CustomNavigationButton :**
- Widget réutilisable pour boutons navigation
- Enum NavigationButtonStyle (green/purple)
- Utilise assets images comme fond
- Bouton "Précédent" : fond lavande, texte violet foncé
- Bouton "Suivant" : fond vert, texte vert foncé

**Boutons mobile :**
- Positionnés sous la carte (dans le scroll)
- Aligned avec les marges de la carte (5% de chaque côté)
- Prennent toute la largeur avec Expanded
- Écart de 16px entre les deux
- Utilisation de CustomNavigationButton

**Optimisations mobile :**
- Padding carte réduit : 20px vertical (vs 40px initial)
- Toggles compacts (Switch scale 0.85)
- Titre : 18px (vs 24px desktop)
- Étape : 13px (vs 16px desktop)

Référence: #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 11:01:43 +01:00
jmartinandCursor 1d774f29eb feat(#78): Migrer PersonalInfoFormScreen vers infrastructure multi-modes
Migration du widget PersonalInfoFormScreen pour utiliser la nouvelle
infrastructure générique :

**Modifications PersonalInfoFormScreen:**
- Ajout paramètre DisplayMode mode (editable/readonly)
- Utilisation de DisplayConfig pour détecter mobile/desktop
- Utilisation de FormFieldRow pour layout responsive
- Adaptation automatique carte vertical/horizontal
- Boutons navigation adaptés mobile/desktop
- Conservation de toutes les fonctionnalités (toggles, validation, etc.)

**Corrections infrastructure:**
- base_form_screen.dart: Correction paramètres ImageButton (bg, textColor)
- form_field_wrapper.dart: Correction paramètres CustomAppTextField
- Gestion correcte des types nullables (TextInputType)

**Résultat:**
✅ Compilation sans erreurs
✅ Layout responsive fonctionnel
✅ Mode editable opérationnel
✅ Prêt pour mode readonly (récaps)

Référence: #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 17:38:54 +01:00
jmartinandCursor 890619ff59 feat(#78): Créer infrastructure générique pour formulaires multi-modes
Nouvelle architecture centralisée pour tous les formulaires :

**Configuration centrale (display_config.dart):**
- DisplayMode enum (editable/readonly)
- LayoutType enum (mobile/desktop)
- DisplayConfig class pour configuration complète
- LayoutHelper avec utilitaires (détection, spacing, etc.)
- Breakpoint: 600px (mobile < 600px reste toujours vertical)

**Widgets génériques (form_field_wrapper.dart):**
- FormFieldWrapper: champ auto-adaptatif (TextField ou Text readonly)
- FormFieldRow: ligne responsive (horizontal desktop, vertical mobile)

**Structure de page (base_form_screen.dart):**
- BaseFormScreen: layout complet avec carte, boutons, navigation
- Gestion auto des assets carte (horizontal/vertical selon layout)

**Avantages:**
✅ Code unique pour editable + readonly + mobile + desktop
✅ Logique centralisée (aucune duplication)
✅ Héritage automatique via DisplayConfig propagé
✅ API simple et cohérente

Prochaine étape: Migration des widgets existants

Référence: #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 17:33:29 +01:00
jmartinandCursor 5d7eb9eb36 fix(#79): Supprimer toutes les références obsolètes à Nanny
Corrections suite au merge master:
- Suppression imports nanny_register_step*.dart (fichiers supprimés)
- Suppression variable nannyRegistrationDataNotifier
- Suppression section Nanny Registration Flow complète
- Ajout import go_router dans login_screen.dart

L'application compile maintenant sans erreurs.

Référence: #79

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:17:49 +01:00
jmartin 45bd8a9ef1 Sync master with develop 2026-02-03 16:12:58 +01:00
jmartinandCursor acb8e72a7c Merge remote-tracking branch 'origin/master' into master
Résolution conflit: Suppression de frontend/lib/navigation/app_router.dart
(fichier obsolète remplacé par frontend/lib/config/app_router.dart)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:12:29 +01:00
jmartin b6c70a52ac Merge develop into master
Synchronisation complète de develop vers master.

Inclut:
- Backend: API auth, config, documents légaux (#3-#9, #31, #75-#77)
- Database: Schéma unifié, tables config/documents (#3-#7)
- Documentation: Architecture, déploiement, tickets (#61-#62)
- Frontend: Inscription parents/AM, dashboard (#36-#42)
- Frontend: Renommage Nanny→AM (#79)
- Frontend: Widgets génériques réutilisables (#80)
- Frontend: Corrections post-refactoring (#81)

80 commits mergés - master à jour avec develop
2026-02-03 16:10:47 +01:00
jmartin 96794919a8 refactor(widgets): Extraire ProfessionalInfoFormScreen en widget réutilisable
Nouveau widget professional_info_form_screen.dart :
- Formulaire complet d'infos professionnelles pour AM
- Gestion de la photo avec sélection et consentement
- Champs : ville/pays/date de naissance, NIR, agrément, capacité
- Validations intégrées (NIR 13 chiffres, capacité > 0, etc.)

AM Step 2 refactorisé :
- Utilise le nouveau ProfessionalInfoFormScreen
- Code réduit de ~280 lignes à ~75 lignes
- Logique de génération de données de test préservée
- Préparé pour réutilisation dans les récapitulatifs

Impact : -205 lignes de code
2026-01-28 17:09:41 +01:00
jmartin 271dc713a3 feat(widgets): Créer composants réutilisables pour écrans de récapitulatif
Nouveau fichier summary_screen.dart avec :
- Widget SummaryScreen : Layout générique pour récapitulatif
- Widget SummaryCard : Carte de récapitulatif avec AspectRatio et bouton Edit
- Fonction buildDisplayFieldValue : Champ en lecture seule stylisé

Ces composants permettront de simplifier et unifier les écrans
de récapitulatif parent et AM.
2026-01-28 17:03:10 +01:00
jmartin 13741b0430 chore(auth): Supprimer les fichiers nanny obsolètes
Suppression des 4 fichiers nanny_register_step*.dart qui sont obsolètes
après le renommage complet en "am" (Assistante Maternelle).

Les nouveaux fichiers correspondants sont :
- am_register_step1_screen.dart
- am_register_step2_screen.dart
- am_register_step3_screen.dart
- am_register_step4_screen.dart
2026-01-28 17:00:52 +01:00
jmartin 8e3af711e5 refactor(widgets): Extraire ChildCardWidget dans un fichier séparé
Extraction du widget _ChildCardWidget de parent_register_step3_screen.dart
vers un fichier réutilisable child_card_widget.dart

Améliorations :
- Widget désormais public (ChildCardWidget au lieu de _ChildCardWidget)
- Réutilisable dans d'autres écrans (ex: récapitulatifs détaillés)
- Imports nettoyés et simplifiés
- Meilleure organisation du code

Le widget gère :
- Photo de l'enfant avec sélection d'image
- Toggle "Enfant à naître"
- Champs: Prénom, Nom, Date de naissance
- Checkboxes: Consentement photo, Naissance multiple
- Bouton de suppression (si > 1 enfant)
2026-01-28 17:00:40 +01:00
jmartin e700e50924 fix(widgets): Ajouter 2 toggles côte à côte comme l'ancien design
Structure correcte pour Parent Step 2 :
- Toggle gauche : "Ajouter Parent 2 ?" avec icône person_add_alt_1
- Toggle droit : "Même Adresse ?" avec icône home_work_outlined
- Les 2 toggles sont dans une Row (flex: 12 chacun)
- Toggle "Même Adresse" grisé si Parent 2 désactivé
- Suppression de l'ancienne checkbox en bas

Conforme à l'ancien code testé et validé.
2026-01-28 16:52:04 +01:00
jmartin 36ef0f8d5c fix(widgets): Repositionner le toggle et checkbox dans la carte
- Toggle "Il y a un 2ème parent ?" maintenant DANS la carte (pas au-dessus)
- Checkbox "Même adresse que parent 1" reste dans la carte
- Taille du texte du toggle ajustée à 20px pour cohérence
- Espacement de 25px après le toggle

Position correcte conforme à l'ancien design.
2026-01-28 16:51:02 +01:00
jmartin f09deb5efc fix(auth): Correction des erreurs de compilation
Corrections des appels de méthodes et des types :

1. Parent Steps 1-2 : Passer des objets ParentData complets
   - updateParent1(ParentData(...)) au lieu de paramètres nommés
   - updateParent2(ParentData(...)) ou null pour supprimer

2. AM Step 1 : Utiliser la bonne méthode
   - updateIdentityInfo() au lieu de updatePersonalInfo()

3. personal_info_form_screen : Corrections widgets
   - Accès correct à widget.stepText
   - Gestion du nullable sur onChanged de AppCustomCheckbox

Ces corrections permettent la compilation sans erreur.
2026-01-28 16:47:10 +01:00
jmartin 26a0e31b32 fix(router): Mise à jour du routeur principal
- Ajout des imports pour les nouveaux écrans AM
- Mise à jour des routes /am-register-step1 à step4
- Suppression de la route /am-register-confirmation (obsolète)
- Configuration du Provider AmRegistrationData
- Nettoyage des imports inutilisés

Les routes AM sont maintenant complètes et fonctionnelles.
2026-01-28 16:43:55 +01:00
jmartin 21430dca41 refactor(auth): Refactoring écrans avec widgets génériques
Refactorisation des écrans d'inscription pour utiliser les nouveaux widgets :

Parent Step 1 (227 → 65 lignes, -71%)
- Utilise personal_info_form_screen
- Conserve préremplissage des données de test
- Couleur : peach

Parent Step 2 (273 → 90 lignes, -67%)
- Utilise personal_info_form_screen
- Toggle "Il y a un 2ème parent"
- Checkbox "Même adresse que parent 1"
- Couleur : blue

Parent Step 4 (247 → 42 lignes, -83%)
- Utilise presentation_form_screen
- Formulaire de motivation
- Couleur : green

AM Step 1 (209 → 65 lignes, -69%)
- Utilise personal_info_form_screen
- Conserve préremplissage des données de test
- Couleur : blue

AM Step 3 (195 → 45 lignes, -77%)
- Utilise presentation_form_screen
- Formulaire de présentation
- Couleur : peach

Total : -709 lignes de code maintenable !
2026-01-28 16:43:47 +01:00
jmartin dcb81d3feb feat(widgets): Création de widgets génériques réutilisables
Création de 2 nouveaux widgets génériques pour réduire la duplication :

1. presentation_form_screen.dart
   - Widget pour formulaires de présentation/motivation
   - Paramétrable : titre, couleur, hint, routes
   - Utilisé par Parent Step 4 et AM Step 3
   - Réduction de ~350 lignes de code dupliqué

2. personal_info_form_screen.dart
   - Widget pour formulaires d'informations personnelles
   - Gère nom, prénom, téléphone, email, adresse
   - Options : toggle "2ème parent", checkbox "même adresse"
   - Utilisé par Parent Steps 1-2 et AM Step 1
   - Réduction de ~460 lignes de code dupliqué

Avantages :
- Maintenance simplifiée (1 seul fichier à modifier)
- Cohérence visuelle garantie entre tous les écrans
- Extensibilité facile pour nouveaux types d'utilisateurs
2026-01-28 16:43:36 +01:00
jmartin 7c86feeb78 chore(auth): Suppression des fichiers obsolètes et doublons
- Suppression de l'ancien routeur navigation/app_router.dart
- Suppression du dossier /parent/ (versions dupliquées)
- Suppression du dossier /am/ (versions de travail temporaires)

Ces fichiers sont remplacés par les versions actives dans auth/
2026-01-28 16:43:25 +01:00
jmartin df87abbb85 feat(auth): Renommer "Nanny" en "Assistante Maternelle" (AM)
- Création du modèle am_registration_data.dart
- Création des 4 écrans d'inscription AM (steps 1-4)
- Mise à jour du bouton "Assistante Maternelle" dans register_choice
- Conformité CDC : pas de champs mot de passe dans les formulaires
- Préremplissage des données de test pour faciliter le développement

Ref: Ticket #XX - Renommage workflow inscription AM
2026-01-28 16:43:16 +01:00
jmartin bd81561e41 Merge branch 'master' into develop 2026-01-27 16:51:02 +01:00
jmartin cc96ef20e1 Merge branch 'master' of https://git.ptits-pas.fr/jmartin/petitspas 2026-01-27 16:49:44 +01:00
jmartin a4ac65a5db Merge feature/47: Modale de changement de mot de passe obligatoire 2026-01-27 16:49:37 +01:00
jmartin 3d13eb5b2e [Doc] Ajout tâche amélioration libellé consentement photo (#62)
Ajout d'une tâche dans le suivi des évolutions du CDC :
- Note pour améliorer le libellé de la checkbox de consentement photo
  sur l'écran d'inscription des nounous (étape 2)

Le libellé actuel 'J'accepte l'utilisation de ma photo' devra être
rendu plus explicite et conforme RGPD.

Refs: #62 (Amendement CDC)
2026-01-27 16:44:23 +01:00
jmartin 5b37d09fa9 [Doc] Guide d'architecture technique et déploiement (#61 #16)
Ajout d'une documentation technique complète pour l'infrastructure
et le déploiement de l'application P'titsPas.

Contenu du guide :
- Vue d'ensemble de l'architecture (Flutter frontend + Node.js backend)
- Prérequis serveur (Node.js, PostgreSQL, ressources recommandées)
- Instructions d'installation pas à pas
- Configuration de la base de données PostgreSQL
- Déploiement du backend (NestJS)
- Build et déploiement du frontend Flutter Web
- Configuration NGINX comme reverse proxy
- Sécurisation SSL/TLS avec Let's Encrypt
- Monitoring et maintenance
- Sauvegarde et restauration
- Troubleshooting des problèmes courants

Ce document est essentiel pour le déploiement on-premise de l'application
par les collectivités locales.

Refs: #61 (Guide installation & configuration), #16 (Doc config on-premise)
2026-01-27 16:44:23 +01:00
jmartin 53f3af9794 [Frontend] Ajout nouvelles icônes SVG de l'application
Ajout de deux versions d'icônes SVG pour l'application :
- icon.svg : Icône standard de l'application
- icon_improved.svg : Version améliorée de l'icône

Ces icônes seront utilisées pour le branding de l'application
et les différentes tailles d'affichage.
2026-01-27 16:44:23 +01:00
jmartin 105cf53e7b [Frontend] Parcours complet inscription Assistantes Maternelles (#40 #41 #42)
Implémentation du parcours d'inscription des assistantes maternelles en 4 étapes
+ écran de confirmation, en utilisant Provider pour la gestion d'état.

Fonctionnalités implémentées :
- Étape 1 : Identité (nom, prénom, adresse, email, mot de passe)
- Étape 2 : Infos professionnelles (photo, agrément, NIR, capacité d'accueil)
- Étape 3 : Présentation personnelle et acceptation CGU
- Étape 4 : Récapitulatif et validation finale
- Écran de confirmation post-inscription

Fichiers ajoutés :
- models/nanny_registration_data.dart : Modèle de données avec Provider
- screens/auth/nanny_register_step1_screen.dart : Identité
- screens/auth/nanny_register_step2_screen.dart : Infos pro
- screens/auth/nanny_register_step3_screen.dart : Présentation
- screens/auth/nanny_register_step4_screen.dart : Récapitulatif
- screens/auth/nanny_register_confirmation_screen.dart : Confirmation
- screens/unknown_screen.dart : Écran pour routes inconnues
- config/app_router.dart : Copie du routeur (à intégrer)

Refs: #40 (Panneau 1 Identité), #41 (Panneau 2 Infos pro), #42 (Finalisation)
2026-01-27 16:44:23 +01:00
jmartin 29bee9fa80 [Frontend] Refactorisation inscription Parents avec Provider (#38 #39)
Refactorisation complète du parcours d'inscription des parents pour utiliser
Provider au lieu du passage de données par paramètres de navigation.

Modifications principales :
- Utilisation de Provider pour partager UserRegistrationData entre les étapes
- Simplification du routeur (suppression des paramètres)
- Amélioration de la persistance des données entre les étapes
- Meilleure expérience utilisateur lors de la navigation

Fichiers modifiés :
- models/user_registration_data.dart : Modèle avec ChangeNotifier
- screens/auth/parent_register_step1-5_screen.dart : Intégration Provider
- navigation/app_router.dart : Simplification du routing
- main.dart : Configuration du Provider
- login_screen.dart : Ajout navigation vers inscription
- register_choice_screen.dart : Navigation vers parcours parent/AM
- utils/data_generator.dart : Génération de données de test

Refs: #38 (Étape 3 Enfants), #39 (Étapes 4-6 Finalisation)
2026-01-27 16:44:23 +01:00
Julien Martin dbd56637e1 chore: ignore les fichiers générés par Flutter pour Android et Windows 2026-01-27 16:44:23 +01:00
jmartin 264e0d49ae fix: Configuration CORS explicite pour dev localhost 2026-01-27 16:36:53 +01:00
jmartin fe71fdf28e feat(#47): Ajout de la modale de changement de mot de passe obligatoire
Implémentation complète du ticket #47 :
- Mise à jour de l'URL API vers app.ptits-pas.fr
- Ajout du champ changement_mdp_obligatoire au modèle AppUser
- Ajout des endpoints /auth/me et /auth/change-password-required
- Implémentation de la vraie logique de connexion dans AuthService
- Création de la modale ChangePasswordDialog non-dismissible
- Connexion du bouton de connexion avec gestion de la modale
- Ajout des routes admin-dashboard et parent-dashboard

La modale s'affiche automatiquement après connexion si
changement_mdp_obligatoire = true et bloque l'utilisateur jusqu'au
changement de mot de passe.
2026-01-27 16:30:15 +01:00
112 changed files with 10457 additions and 5148 deletions
+18
View File
@@ -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
+4
View File
@@ -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/
+3
View File
@@ -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
+27
View File
@@ -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)
},
}),
);
}
}
+13 -5
View File
@@ -1,17 +1,25 @@
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'] });
app.enableCors();
// 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
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
@@ -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);
+3 -3
View File
@@ -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' })
+10 -6
View File
@@ -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()
+2 -1
View File
@@ -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({
+79 -121
View File
@@ -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],
})
+1 -1
View File
@@ -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();
+2
View File
@@ -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],
+6 -3
View File
@@ -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
);
-- ==========================================================
+10
View File
@@ -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/`.
+73
View File
@@ -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;
+2
View File
@@ -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:
+1
View File
@@ -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
+37
View File
@@ -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.
+59 -104
View File
@@ -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é
+252 -120
View File
@@ -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
+6 -5
View File
@@ -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é
+176
View File
@@ -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.
+63
View File
@@ -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(),
);
// ...
}
```
+592
View File
@@ -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.
+5 -4
View File
@@ -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*
+1
View File
@@ -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
+35
View File
@@ -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']
+176
View File
@@ -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.
+115
View File
@@ -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 -1
View File
@@ -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

+6
View File
@@ -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

+91
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

+126
View File
@@ -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(),
),
],
),
],
);
}
+106
View File
@@ -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;
}
}
}
+2 -2
View File
@@ -2,11 +2,11 @@ class Env {
// Base URL de l'API, surchargeable à la compilation via --dart-define=API_BASE_URL
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://ynov.ptits-pas.fr',
defaultValue: 'https://app.ptits-pas.fr',
);
// 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';
}
+3 -4
View File
@@ -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?,
);
}
}
+18
View File
@@ -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,
);
}
}
+65 -6
View File
@@ -4,6 +4,15 @@ class AppUser {
final String role;
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,
@@ -11,15 +20,54 @@ class AppUser {
required this.role,
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),
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(),
);
}
@@ -30,6 +78,17 @@ class AppUser {
'role': role,
'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;
}
}
-105
View File
@@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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 '../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 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;
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');
},
),
],
);
},
);
}
}
+356 -45
View File
@@ -1,32 +1,51 @@
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:p_tits_pas/services/bug_report_service.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();
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';
@@ -41,24 +60,98 @@ class _LoginPageState extends State<LoginPage> {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe';
}
if (value.length < 6) {
return 'Le mot de passe doit contenir au moins 6 caractères';
}
return null;
}
/// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur
setState(() {
_errorMessage = null;
});
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Appeler le service d'authentification
final user = await AuthService.login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
// Vérifier si l'utilisateur doit changer son mot de passe
if (user.changementMdpObligatoire) {
if (!mounted) return;
// Afficher la modale de changement de mot de passe (non-dismissible)
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const ChangePasswordDialog(),
);
// Si le changement de mot de passe a réussi, rafraîchir l'utilisateur
if (result == true && mounted) {
await AuthService.refreshCurrentUser();
}
}
if (!mounted) return;
// Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role);
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
}
/// 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':
context.go('/admin-dashboard');
break;
case 'parent':
context.go('/parent-dashboard');
break;
case 'assistante_maternelle':
context.go('/am-dashboard');
break;
default:
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) {
@@ -136,20 +229,46 @@ class _LoginPageState extends State<LoginPage> {
],
),
const SizedBox(height: 20),
// Message d'erreur
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 15),
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,
),
),
),
],
),
),
// Bouton centré
Center(
child: ImageButton(
bg: 'assets/images/btn_green.png',
width: 300,
height: 40,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
// TODO: Implémenter la logique de connexion
}
},
),
child: _isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: 300,
height: 40,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleLogin,
),
),
const SizedBox(height: 10),
// Lien mot de passe oublié
@@ -168,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',
@@ -185,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',
@@ -234,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');
},
),
],
@@ -251,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();
@@ -373,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
@@ -384,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> { ... }
+15
View File
@@ -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.'),
),
);
}
}
+15 -1
View File
@@ -1,16 +1,30 @@
class ApiConfig {
// static const String baseUrl = 'http://localhost:3000/api/v1/';
static const String baseUrl = 'https://ynov.ptits-pas.fr/api/v1';
static const String baseUrl = 'https://app.ptits-pas.fr/api/v1';
// 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';
// Users endpoints
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';
+138 -21
View File
@@ -1,42 +1,159 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
class AuthService {
static const String _usersKey = 'users';
static const String _parentsKey = 'parents';
static const String _childrenKey = 'children';
static const String _currentUserKey = 'current_user';
// Méthode pour se connecter (mode démonstration)
/// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async {
await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement
throw Exception('Mode démonstration - Connexion désactivée');
try {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.login}'),
headers: ApiConfig.headers,
body: jsonEncode({
'email': email,
'password': password,
}),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
// 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);
return user;
} else {
final error = jsonDecode(response.body);
throw Exception(error['message'] ?? 'Erreur de connexion');
}
} catch (e) {
if (e is Exception) rethrow;
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de se connecter au serveur ($e)');
}
}
// Méthode pour s'inscrire (mode démonstration)
static Future<AppUser> register({
required String email,
required String password,
required String firstName,
required String lastName,
required String role,
/// Récupère le profil utilisateur via /auth/me
static Future<AppUser> _fetchUserProfile(String token) async {
try {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.authMe}'),
headers: ApiConfig.authHeaders(token),
);
if (response.statusCode == 200) {
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;
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de récupérer le profil ($e)');
}
}
/// Changement de mot de passe obligatoire
static Future<void> changePasswordRequired({
required String currentPassword,
required String newPassword,
}) async {
await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement
throw Exception('Mode démonstration - Inscription désactivée');
final token = await TokenService.getToken();
if (token == null) {
throw Exception('Non authentifié');
}
try {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'),
headers: ApiConfig.authHeaders(token),
body: jsonEncode({
'mot_de_passe_actuel': currentPassword,
'nouveau_mot_de_passe': newPassword,
'confirmation_mot_de_passe': newPassword,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final error = jsonDecode(response.body);
throw Exception(error['message'] ?? 'Erreur lors du changement de mot de passe');
}
// Après le changement de MDP, rafraîchir le profil utilisateur
final user = await _fetchUserProfile(token);
await _saveCurrentUser(user);
} catch (e) {
if (e is Exception) rethrow;
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de changer le mot de passe ($e)');
}
}
// Méthode pour se déconnecter (mode démonstration)
/// Déconnexion de l'utilisateur
static Future<void> logout() async {
// Ne fait rien en mode démonstration
await TokenService.clearAll();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_currentUserKey);
}
// Méthode pour vérifier si l'utilisateur est connecté (mode démonstration)
/// Vérifie si l'utilisateur est connecté
static Future<bool> isLoggedIn() async {
return false; // Toujours non connecté en mode démonstration
final token = await TokenService.getToken();
return token != null;
}
// Méthode pour récupérer l'utilisateur connecté (mode démonstration)
/// Récupère l'utilisateur connecté depuis le cache
static Future<AppUser?> getCurrentUser() async {
return null; // Aucun utilisateur en mode démonstration
final prefs = await SharedPreferences.getInstance();
final userJson = prefs.getString(_currentUserKey);
if (userJson != null) {
return AppUser.fromJson(jsonDecode(userJson));
}
return null;
}
/// Sauvegarde l'utilisateur actuel en cache
static Future<void> _saveCurrentUser(AppUser user) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
}
/// Rafraîchit le profil utilisateur depuis l'API
static Future<AppUser?> refreshCurrentUser() async {
final token = await TokenService.getToken();
if (token == null) return null;
try {
final user = await _fetchUserProfile(token);
await _saveCurrentUser(user);
return user;
} catch (e) {
return null;
}
}
}
@@ -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');
}
}
}
+94
View File
@@ -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 [];
}
}
+5
View File
@@ -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'
+220
View File
@@ -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é
},
),
),
],
+113 -67
View File
@@ -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
),
),
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../services/auth_service.dart';
import '../../widgets/custom_app_text_field.dart';
import '../../widgets/image_button.dart';
/// Dialogue modal bloquant pour le changement de mot de passe obligatoire
/// Utilisé après la première connexion quand changement_mdp_obligatoire = true
class ChangePasswordDialog extends StatefulWidget {
const ChangePasswordDialog({super.key});
@override
State<ChangePasswordDialog> createState() => _ChangePasswordDialogState();
}
class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
final _formKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
String? _validateCurrentPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe actuel';
}
return null;
}
String? _validateNewPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un nouveau mot de passe';
}
if (value.length < 8) {
return 'Le mot de passe doit contenir au moins 8 caractères';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins une majuscule';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins une minuscule';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins un chiffre';
}
return null;
}
String? _validateConfirmPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez confirmer votre nouveau mot de passe';
}
if (value != _newPasswordController.text) {
return 'Les mots de passe ne correspondent pas';
}
return null;
}
Future<void> _handleSubmit() async {
// Réinitialiser le message d'erreur
setState(() {
_errorMessage = null;
});
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
try {
await AuthService.changePasswordRequired(
currentPassword: _currentPasswordController.text,
newPassword: _newPasswordController.text,
);
if (mounted) {
// Fermer le dialogue avec succès
Navigator.of(context).pop(true);
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Mot de passe modifié avec succès',
style: GoogleFonts.merienda(),
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// Empêcher la fermeture du dialogue avec le bouton retour
canPop: false,
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 500),
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Titre
Text(
'Changement de mot de passe obligatoire',
style: GoogleFonts.merienda(
fontSize: 20,
fontWeight: FontWeight.bold,
color: const Color(0xFF2D6A4F),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
// Message d'explication
Text(
'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.',
style: GoogleFonts.merienda(
fontSize: 14,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 25),
// Champ mot de passe actuel
CustomAppTextField(
controller: _currentPasswordController,
labelText: 'Mot de passe actuel',
hintText: 'Votre mot de passe actuel',
obscureText: true,
validator: _validateCurrentPassword,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 15),
// Champ nouveau mot de passe
CustomAppTextField(
controller: _newPasswordController,
labelText: 'Nouveau mot de passe',
hintText: 'Minimum 8 caractères',
obscureText: true,
validator: _validateNewPassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 15),
// Champ confirmation mot de passe
CustomAppTextField(
controller: _confirmPasswordController,
labelText: 'Confirmer le mot de passe',
hintText: 'Retapez le nouveau mot de passe',
obscureText: true,
validator: _validateConfirmPassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 10),
// Critères du mot de passe
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Le mot de passe doit contenir :',
style: GoogleFonts.merienda(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 5),
_buildPasswordCriteria('Au moins 8 caractères'),
_buildPasswordCriteria('Au moins une majuscule'),
_buildPasswordCriteria('Au moins une minuscule'),
_buildPasswordCriteria('Au moins un chiffre'),
],
),
),
// Message d'erreur
if (_errorMessage != null) ...[
const SizedBox(height: 15),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
),
),
),
],
),
),
],
const SizedBox(height: 25),
// Bouton de validation
Center(
child: _isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: 250,
height: 40,
text: 'Changer le mot de passe',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleSubmit,
),
),
],
),
),
),
),
);
}
Widget _buildPasswordCriteria(String text) {
return Padding(
padding: const EdgeInsets.only(top: 3, left: 5),
child: Row(
children: [
const Icon(Icons.check_circle_outline, size: 14, color: Colors.green),
const SizedBox(width: 8),
Text(
text,
style: GoogleFonts.merienda(
fontSize: 11,
color: Colors.black87,
),
),
],
),
);
}
}
+225
View File
@@ -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,
),
),
],
);
}
}
+591
View File
@@ -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,
),
],
);
}
}
+15 -11
View File
@@ -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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More