From 4f54340e89b538485a90e35d4736c21279239cad Mon Sep 17 00:00:00 2001 From: Azmog <80716388+AzmogEx@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:17:40 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20corrections=20post-audit=20(tra=C3=A7abi?= =?UTF-8?q?lit=C3=A9,=20identit=C3=A9=20=C3=89tat,=20accents,=20graphique?= =?UTF-8?q?=20mobile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - table audits réellement alimentée via trait Auditable (observers Eloquent) sur Utilisateur, SaisieTracker, Feed, Emotion ; mot de passe jamais loggé - 4 nouveaux tests (19/19 au total) Frontend web: - en-tête/pied République Française ajoutés aux pages connexion/inscription - bouton variant danger réellement rouge (tokens erreur DSFR) Mobile: - ~30 libellés ré-accentués (Intensité, Succès, Déconnexion, Répartition...) - Colors.secondary = rouge Marianne #E1000F (différenciation cartes/icônes) - vrai camembert SVG des rapports (react-native-svg) Docs: plans oraux mis à jour (audits actif, 29 tests, stockage JWT en Q/R) Co-Authored-By: Claude Opus 4.8 (1M context) --- Plan2_Oral_CESIZen.md | 10 +- Plan_Oral_CESIZen.md | 6 +- backend/app/Models/Concerns/Auditable.php | 86 ++++++++++ backend/app/Models/Emotion.php | 3 +- backend/app/Models/Feed.php | 3 +- backend/app/Models/SaisieTracker.php | 3 +- backend/app/Models/Utilisateur.php | 3 +- backend/tests/Feature/AuditTest.php | 96 ++++++++++++ frontend/src/app/(auth)/layout.tsx | 17 +- frontend/src/app/globals.css | 13 ++ frontend/src/components/ui/Button.tsx | 2 +- mobile/app/(tabs)/_layout.tsx | 2 +- mobile/app/(tabs)/index.tsx | 6 +- mobile/app/(tabs)/info/_layout.tsx | 2 +- mobile/app/(tabs)/journal/edit.tsx | 8 +- mobile/app/(tabs)/journal/index.tsx | 4 +- mobile/app/(tabs)/journal/new.tsx | 6 +- mobile/app/(tabs)/journal/reports.tsx | 24 ++- mobile/app/(tabs)/profile.tsx | 10 +- mobile/app/auth/login.tsx | 2 +- mobile/components/EmotionPicker.tsx | 4 +- mobile/components/EmotionPieChart.tsx | 129 +++++++++++++++ mobile/lib/colors.ts | 6 +- mobile/package-lock.json | 183 +++++++++++++++++++++- mobile/package.json | 1 + 25 files changed, 580 insertions(+), 49 deletions(-) create mode 100644 backend/app/Models/Concerns/Auditable.php create mode 100644 backend/tests/Feature/AuditTest.php create mode 100644 mobile/components/EmotionPieChart.tsx diff --git a/Plan2_Oral_CESIZen.md b/Plan2_Oral_CESIZen.md index 72f5588..8d6a732 100644 --- a/Plan2_Oral_CESIZen.md +++ b/Plan2_Oral_CESIZen.md @@ -13,7 +13,7 @@ - **6** émotions de base **+ 36** sous-émotions (référentiel du sujet) - **8** entités au MCD · **1** API REST → **2** clients (web + mobile) - JWT **60 min** + refresh · throttle login **5/min** -- **25** tests automatisés (15 back + 6 front + 4 mobile) +- **29** tests automatisés (19 back + 6 front + 4 mobile) - **DSFR** : interface au Système de Design de l'État (charte gouv.fr) — web + mobile - **Déployé en ligne** : https://cesizen.cleanows.fr (Docker + HTTPS, vrai service en prod) - Contraintes sujet : budget **75 000 €**, livraison **12 mois** @@ -57,11 +57,11 @@ **16:00–17:30 · RGPD & sécurité (slide 13)** - Données de santé = sensibles (art. 9) · consentement · droits CNIL (export/anonymisation) -- bcrypt (mdp) · soft delete · table audits · AES-256 prévu en prod HDS · TLS +- bcrypt (mdp) · soft delete · **table audits alimentée** (observers Eloquent : création/modif/suppression tracées) · AES-256 prévu en prod HDS · TLS - ➡️ *« Tout cela est concrétisé par un prototype. »* **17:30–19:00 · Prototype & perspectives (slide 14)** -- Stack réelle : Laravel + Next.js + Expo + Docker · **25 tests automatisés** +- Stack réelle : Laravel + Next.js + Expo + Docker · **29 tests automatisés** - **Déployé en ligne** : https://cesizen.cleanows.fr (Coolify, conteneurs Docker, HTTPS) → *« ce n'est pas qu'une maquette, c'est en production »* - Ouverture : activer les modules écartés sans réécrire · Mon Espace Santé (FHIR) · Open Data @@ -100,8 +100,10 @@ - **Données sensibles / RGPD ?** → consentement · droits CNIL implémentés · HDS · bcrypt + AES prévu - **Soft delete vs anonymisation ?** → soft delete = réversible 30j ; anonymisation = dissocie identité - **JWT, risques ?** → stateless/scalable · expiration courte + refresh · throttle anti-bruteforce · MFA prévu +- **Où est stocké le token ?** → mobile = SecureStore (chiffré OS) · web = localStorage (limite XSS assumée, cible = cookie HttpOnly en v2) +- **Traçabilité ?** → table audits **réellement alimentée** par observers Eloquent (qui/quoi/quand : action, table, anciennes/nouvelles valeurs, IP) · mdp jamais loggé - **Scalabilité 5000 users ?** → API stateless · index Postgres · pagination · cache TanStack Query -- **Réellement développé ?** → prototype 3 modules (API+web+mobile) + 25 tests automatisés · **déployé en ligne** (Coolify/Docker/HTTPS) +- **Réellement développé ?** → prototype 3 modules (API+web+mobile) + 29 tests automatisés · **déployé en ligne** (Coolify/Docker/HTTPS) - **Pourquoi le DSFR ?** → service de l'État → charte obligatoire gouv.fr · accessibilité RGAA native · cohérence + confiance · gain de temps (composants prêts) - **Vraiment en production ?** → oui : https://cesizen.cleanows.fr · conteneurs Docker · base PostgreSQL · CI de build · HTTPS diff --git a/Plan_Oral_CESIZen.md b/Plan_Oral_CESIZen.md index 205e338..d915453 100644 --- a/Plan_Oral_CESIZen.md +++ b/Plan_Oral_CESIZen.md @@ -21,8 +21,8 @@ | **6:00 – 8:30** (2′30) | 7 — 8 | **Réponse fonctionnelle (Comptes + Informations)** | Module Comptes : JWT, RGPD, admin (slide 7 + maquette). Module Informations : feeds, modération, slug auto (slide 8 + maquette). 💬 Point fort transversal : **toute l'interface (web + mobile) suit le Système de Design de l'État (DSFR)** — la charte officielle gouv.fr → crédibilité institutionnelle + accessibilité native. Enchaîner : « pour le Tracker, je vais vous le montrer en direct. » | | **8:30 – 12:30** (4′) | démo live | **🎬 Démo de l'application — Tracker** | Sur l'app (compte `demo@cesizen.fr` **déjà connecté**) — **en ligne** sur https://cesizen.cleanows.fr **ou** en local : page Informations → journal de bord → **nouvelle saisie** (wizard 3 étapes : émotion → sous-émotion + intensité → date + note) → **Rapports** (changer la période : camembert + courbe d'évolution). 💬 Insister sur l'en-tête « République Française » (DSFR). 🛟 Si ça plante : basculer sur les maquettes (slide 9) sans s'excuser. | | **12:30 – 16:00** (3′30) | 10 — 11 — 12 | **Modélisation** | Cas d'usage UML (slide 10) : couverture par acteur. MCD Merise (slide 11) : 8 entités, cardinalités, hiérarchie d'émotions auto-référencée. Architecture MVC découplée (slide 12) : 1 API REST Laravel → 2 clients ; justifier le pattern (testabilité, évolutivité, non-duplication). | -| **16:00 – 17:30** (1′30) | 13 | **RGPD & données sensibles** | Données de santé = sensibles (art. 9). Consentement, droits CNIL (export/anonymisation), soft delete. Sécurité : bcrypt (mots de passe), JWT, HDS, table audits ; chiffrement AES-256 au repos prévu en production. | -| **17:30 – 19:00** (1′30) | 14 | **Prototype & perspectives** | Prototype fonctionnel (Laravel + Next.js + Expo + Docker), **25 tests automatisés** (15 back + 6 front + 4 mobile), **interface conforme DSFR** et **déployé en ligne** (Coolify, conteneurs Docker, HTTPS) sur https://cesizen.cleanows.fr — pas qu'une maquette, un vrai service en production. Ouverture : activer les modules écartés sans réécrire le modèle, Open Data, Mon Espace Santé (FHIR). | +| **16:00 – 17:30** (1′30) | 13 | **RGPD & données sensibles** | Données de santé = sensibles (art. 9). Consentement, droits CNIL (export/anonymisation), soft delete. Sécurité : bcrypt (mots de passe), JWT, HDS, **table audits réellement alimentée** (observers Eloquent : création/modification/suppression tracées avec anciennes/nouvelles valeurs + IP, mot de passe jamais loggé) ; chiffrement AES-256 au repos prévu en production. | +| **17:30 – 19:00** (1′30) | 14 | **Prototype & perspectives** | Prototype fonctionnel (Laravel + Next.js + Expo + Docker), **29 tests automatisés** (19 back + 6 front + 4 mobile), **interface conforme DSFR** et **déployé en ligne** (Coolify, conteneurs Docker, HTTPS) sur https://cesizen.cleanows.fr — pas qu'une maquette, un vrai service en production. Ouverture : activer les modules écartés sans réécrire le modèle, Open Data, Mon Espace Santé (FHIR). | | **19:00 – 20:00** (1′) | 14 | **Conclusion** | Synthèse : besoin reformulé → périmètre priorisé → réponse fonctionnelle → architecture pérenne → conformité. Remercier, ouvrir les questions. | **Repères de rythme :** checkpoint mental à **12:30** (fin de la démo). Si retard, raccourcir la modélisation (garder MCD + MVC, survoler les cas d'usage) et garder 1 min pour conclure. ⚠️ **Lance la stack et connecte-toi AVANT d'entrer** dans la salle (sois déjà sur le journal de bord). @@ -73,7 +73,7 @@ ### Questions « piège » fréquentes **Q13. Qu'est-ce qui est réellement développé vs spécifié ?** -> Un prototype fonctionnel des 3 modules (API + web + mobile) accompagne le dossier, avec **25 tests automatisés** (15 PHPUnit back, 6 Vitest web, 4 Jest mobile) ; les spécifications détaillées couvrent les 2 modules obligatoires + le Tracker, conformément à la consigne. +> Un prototype fonctionnel des 3 modules (API + web + mobile) accompagne le dossier, avec **29 tests automatisés** (19 PHPUnit back, 6 Vitest web, 4 Jest mobile) ; les spécifications détaillées couvrent les 2 modules obligatoires + le Tracker, conformément à la consigne. **Q14. Si c'était à refaire, que changeriez-vous ?** > J'ajouterais des tests automatisés d'accessibilité RGAA dès le début, et je formaliserais un diagramme de classes en complément du MCD pour la couche métier. diff --git a/backend/app/Models/Concerns/Auditable.php b/backend/app/Models/Concerns/Auditable.php new file mode 100644 index 0000000..5e34184 --- /dev/null +++ b/backend/app/Models/Concerns/Auditable.php @@ -0,0 +1,86 @@ + + */ + protected static array $champsAuditExclus = [ + 'password', + 'remember_token', + ]; + + /** + * Enregistre les hooks d'audit sur les événements du modèle. + */ + public static function bootAuditable(): void + { + static::created(function (Model $model) { + $model->ecrireAudit('creation', null, $model->valeursAuditables($model->getAttributes())); + }); + + static::updated(function (Model $model) { + $modifies = array_keys($model->getDirty()); + + if ($modifies === []) { + return; + } + + $anciennes = $model->valeursAuditables($model->getOriginal()); + $nouvelles = $model->valeursAuditables($model->getDirty()); + + $model->ecrireAudit( + 'modification', + array_intersect_key($anciennes, $nouvelles), + $nouvelles + ); + }); + + static::deleted(function (Model $model) { + $model->ecrireAudit('suppression', $model->valeursAuditables($model->getAttributes()), null); + }); + } + + /** + * Filtre les champs sensibles d'un jeu d'attributs. + * + * @param array $valeurs + * @return array + */ + protected function valeursAuditables(array $valeurs): array + { + return array_diff_key($valeurs, array_flip(static::$champsAuditExclus)); + } + + /** + * Insère une ligne d'audit. + * + * @param array|null $anciennes + * @param array|null $nouvelles + */ + protected function ecrireAudit(string $action, ?array $anciennes, ?array $nouvelles): void + { + Audit::create([ + 'utilisateur_id' => auth()->id(), + 'action' => $action, + 'table_cible' => $this->getTable(), + 'enregistrement_id' => $this->getKey(), + 'anciennes_valeurs' => $anciennes, + 'nouvelles_valeurs' => $nouvelles, + 'ip_address' => request()->ip(), + ]); + } +} diff --git a/backend/app/Models/Emotion.php b/backend/app/Models/Emotion.php index 2bc4796..facf501 100644 --- a/backend/app/Models/Emotion.php +++ b/backend/app/Models/Emotion.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Emotion extends Model { - use HasFactory; + use Auditable, HasFactory; protected $table = 'emotions'; diff --git a/backend/app/Models/Feed.php b/backend/app/Models/Feed.php index 71cab5c..c4ed610 100644 --- a/backend/app/Models/Feed.php +++ b/backend/app/Models/Feed.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Feed extends Model { - use HasFactory; + use Auditable, HasFactory; protected $table = 'feeds'; diff --git a/backend/app/Models/SaisieTracker.php b/backend/app/Models/SaisieTracker.php index 08b80bb..9a5316f 100644 --- a/backend/app/Models/SaisieTracker.php +++ b/backend/app/Models/SaisieTracker.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class SaisieTracker extends Model { - use HasFactory; + use Auditable, HasFactory; protected $table = 'saisie_trackers'; diff --git a/backend/app/Models/Utilisateur.php b/backend/app/Models/Utilisateur.php index 6a1cb34..a45459e 100644 --- a/backend/app/Models/Utilisateur.php +++ b/backend/app/Models/Utilisateur.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Auditable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,7 +14,7 @@ use Tymon\JWTAuth\Contracts\JWTSubject; class Utilisateur extends Authenticatable implements JWTSubject { - use HasFactory, Notifiable, SoftDeletes; + use Auditable, HasFactory, Notifiable, SoftDeletes; protected $table = 'utilisateurs'; diff --git a/backend/tests/Feature/AuditTest.php b/backend/tests/Feature/AuditTest.php new file mode 100644 index 0000000..dd4a301 --- /dev/null +++ b/backend/tests/Feature/AuditTest.php @@ -0,0 +1,96 @@ +seed(RoleSeeder::class); + + $role = Role::where('nom', 'membre')->first(); + $this->auteur = Utilisateur::create([ + 'nom' => 'Audit', + 'prenom' => 'Test', + 'email' => 'audit@test.fr', + 'password' => 'Password123!', + 'role_id' => $role->id, + 'consentement_rgpd' => true, + ]); + } + + private function creerFeed(array $attributs = []): Feed + { + return Feed::create(array_merge([ + 'titre' => 'Article test', + 'slug' => 'article-test-'.uniqid(), + 'contenu' => 'Contenu', + 'est_publie' => true, + 'ordre' => 1, + 'auteur_id' => $this->auteur->id, + ], $attributs)); + } + + public function test_la_creation_d_un_feed_genere_un_audit(): void + { + $feed = $this->creerFeed(); + + $this->assertDatabaseHas('audits', [ + 'action' => 'creation', + 'table_cible' => 'feeds', + 'enregistrement_id' => $feed->id, + ]); + } + + public function test_la_modification_genere_un_audit_avec_anciennes_et_nouvelles_valeurs(): void + { + $feed = $this->creerFeed(['titre' => 'Avant']); + + $feed->update(['titre' => 'Apres']); + + $audit = Audit::where('action', 'modification') + ->where('enregistrement_id', $feed->id) + ->first(); + + $this->assertNotNull($audit); + $this->assertSame('Avant', $audit->anciennes_valeurs['titre']); + $this->assertSame('Apres', $audit->nouvelles_valeurs['titre']); + } + + public function test_la_suppression_genere_un_audit(): void + { + $feed = $this->creerFeed(); + $id = $feed->id; + $feed->delete(); + + $this->assertDatabaseHas('audits', [ + 'action' => 'suppression', + 'table_cible' => 'feeds', + 'enregistrement_id' => $id, + ]); + } + + public function test_le_mot_de_passe_n_est_pas_stocke_dans_l_audit(): void + { + $audit = Audit::where('table_cible', 'utilisateurs') + ->where('enregistrement_id', $this->auteur->id) + ->first(); + + $this->assertNotNull($audit); + $this->assertArrayNotHasKey('password', $audit->nouvelles_valeurs); + $this->assertArrayNotHasKey('remember_token', $audit->nouvelles_valeurs); + } +} diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx index ed2e88b..ffe6273 100644 --- a/frontend/src/app/(auth)/layout.tsx +++ b/frontend/src/app/(auth)/layout.tsx @@ -1,13 +1,22 @@ +import Header from '@/components/layout/Header'; +import Footer from '@/components/layout/Footer'; + export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return ( -
-
-
{children}
-
+
+
+
+
+
+
{children}
+
+
+
+
); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6f9b5c4..7af8578 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10,3 +10,16 @@ border: 1px solid var(--border-default-grey); border-radius: 0.25rem; } + +/* Bouton d'action destructive (rouge) — le DSFR ne fournit pas de variante + « danger » native, on surcharge donc les couleurs avec les tokens d'erreur. */ +.fr-btn.app-btn--danger { + color: var(--text-inverted-grey); + background-color: var(--background-action-high-error); +} +.fr-btn.app-btn--danger:hover { + background-color: var(--background-action-high-error-hover); +} +.fr-btn.app-btn--danger:active { + background-color: var(--background-action-high-error-active); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 617dfea..a6a784e 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -17,7 +17,7 @@ const variantClasses: Record = { secondary: "fr-btn--secondary", outline: "fr-btn--secondary", ghost: "fr-btn--tertiary-no-outline", - danger: "fr-btn--secondary", + danger: "app-btn--danger", }; const sizeClasses: Record = { diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 6108f13..fab5ee7 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -6,7 +6,7 @@ export default function TabLayout() { return ( {rapport.stats.intensite_moyenne?.toFixed(1) || '—'} - Intensite moy. + Intensité moy. {rapport.stats.emotion_dominante?.icone || '—'} @@ -63,7 +63,7 @@ export default function DashboardScreen() { {recentSaisies.length > 0 && ( - Dernieres emotions + Dernières émotions router.push('/(tabs)/journal')}> Tout voir @@ -76,7 +76,7 @@ export default function DashboardScreen() { {saisie.emotion?.nom} - {new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensite: {saisie.intensite}/10 + {new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité: {saisie.intensite}/10 diff --git a/mobile/app/(tabs)/info/_layout.tsx b/mobile/app/(tabs)/info/_layout.tsx index b2b348e..16af280 100644 --- a/mobile/app/(tabs)/info/_layout.tsx +++ b/mobile/app/(tabs)/info/_layout.tsx @@ -7,7 +7,7 @@ export default function InfoLayout() { screenOptions={{ headerStyle: { backgroundColor: Colors.white }, headerTitleStyle: { fontWeight: '700', color: Colors.black }, - headerTintColor: Colors.secondary, + headerTintColor: Colors.primary, }} > diff --git a/mobile/app/(tabs)/journal/edit.tsx b/mobile/app/(tabs)/journal/edit.tsx index 1048296..22a32e3 100644 --- a/mobile/app/(tabs)/journal/edit.tsx +++ b/mobile/app/(tabs)/journal/edit.tsx @@ -38,7 +38,7 @@ export default function EditEntryScreen() { note: note.trim() || undefined, date_saisie: dateSaisie, }); - Alert.alert('Succes', 'Saisie modifiee !', [ + Alert.alert('Succès', 'Saisie modifiée !', [ { text: 'OK', onPress: () => router.back() }, ]); } catch { @@ -60,13 +60,13 @@ export default function EditEntryScreen() { {/* Emotion */} - Emotion + Émotion {/* Intensite */} - Intensite + Intensité {intensite} /10 @@ -95,7 +95,7 @@ export default function EditEntryScreen() { style={styles.textArea} value={note} onChangeText={setNote} - placeholder="Decrivez ce que vous ressentez..." + placeholder="Décrivez ce que vous ressentez..." multiline numberOfLines={4} textAlignVertical="top" diff --git a/mobile/app/(tabs)/journal/index.tsx b/mobile/app/(tabs)/journal/index.tsx index 87c6602..37d6599 100644 --- a/mobile/app/(tabs)/journal/index.tsx +++ b/mobile/app/(tabs)/journal/index.tsx @@ -45,7 +45,7 @@ export default function JournalScreen() { {item.emotion?.nom} - Intensite: {item.intensite}/10 + Intensité: {item.intensite}/10 @@ -76,7 +76,7 @@ export default function JournalScreen() { Aucune saisie - Commencez par enregistrer votre premiere emotion + Commencez par enregistrer votre première émotion } /> diff --git a/mobile/app/(tabs)/journal/new.tsx b/mobile/app/(tabs)/journal/new.tsx index 6e59f56..6e0a62f 100644 --- a/mobile/app/(tabs)/journal/new.tsx +++ b/mobile/app/(tabs)/journal/new.tsx @@ -22,7 +22,7 @@ export default function NewEntryScreen() { note: note.trim() || undefined, date_saisie: new Date().toISOString().split('T')[0], }); - Alert.alert('Succes', 'Saisie enregistree !', [ + Alert.alert('Succès', 'Saisie enregistrée !', [ { text: 'OK', onPress: () => router.back() }, ]); } catch { @@ -67,7 +67,7 @@ export default function NewEntryScreen() { {/* Step 2: Intensity */} {step === 2 && ( - A quel point ressentez-vous cette emotion ? + À quel point ressentez-vous cette émotion ? {intensite} /10 @@ -115,7 +115,7 @@ export default function NewEntryScreen() { style={styles.textArea} value={note} onChangeText={setNote} - placeholder="Decrivez ce que vous ressentez, ce qui a declenche cette emotion..." + placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..." multiline numberOfLines={5} textAlignVertical="top" diff --git a/mobile/app/(tabs)/journal/reports.tsx b/mobile/app/(tabs)/journal/reports.tsx index 0cd10b4..05b5120 100644 --- a/mobile/app/(tabs)/journal/reports.tsx +++ b/mobile/app/(tabs)/journal/reports.tsx @@ -3,6 +3,7 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-nati import { Ionicons } from '@expo/vector-icons'; import { useRapport } from '@/hooks/useRapport'; import { Colors } from '@/lib/colors'; +import EmotionPieChart from '@/components/EmotionPieChart'; type Period = 'week' | 'month' | 'quarter' | 'year'; @@ -10,7 +11,7 @@ const PERIODS: { value: Period; label: string }[] = [ { value: 'week', label: 'Semaine' }, { value: 'month', label: 'Mois' }, { value: 'quarter', label: 'Trimestre' }, - { value: 'year', label: 'Annee' }, + { value: 'year', label: 'Année' }, ]; export default function ReportsScreen() { @@ -50,10 +51,10 @@ export default function ReportsScreen() { {rapport.stats.intensite_moyenne?.toFixed(1) || '—'} - Intensite moy. + Intensité moy. - + {rapport.stats.emotion_dominante?.icone || '—'} {rapport.stats.emotion_dominante?.nom || 'Aucune'} @@ -69,7 +70,19 @@ export default function ReportsScreen() { {/* Repartition */} {rapport.repartition.length > 0 && ( - Repartition des emotions + Répartition des émotions + + {/* Camembert (donut) — équivalent mobile du graphique Recharts du web */} + ({ + nom: item.nom, + couleur: item.couleur, + count: item.count, + pourcentage: item.pourcentage, + }))} + /> + + Détail par émotion {rapport.repartition.map((item) => ( @@ -92,7 +105,7 @@ export default function ReportsScreen() { ) : ( - Pas de donnees pour cette periode + Pas de données pour cette période )} @@ -120,6 +133,7 @@ const styles = StyleSheet.create({ periodInfoText: { fontSize: 13, color: Colors.gray[400], textAlign: 'center' }, section: { marginBottom: 24 }, sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 16 }, + subSectionTitle: { fontSize: 14, fontWeight: '700', color: Colors.gray[600], marginBottom: 12, marginTop: 4 }, repartitionItem: { marginBottom: 16 }, repartitionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 }, colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 8 }, diff --git a/mobile/app/(tabs)/profile.tsx b/mobile/app/(tabs)/profile.tsx index 2310438..a238bad 100644 --- a/mobile/app/(tabs)/profile.tsx +++ b/mobile/app/(tabs)/profile.tsx @@ -9,10 +9,10 @@ export default function ProfileScreen() { const { user, logout } = useAuthStore(); const handleLogout = () => { - Alert.alert('Deconnexion', 'Voulez-vous vous deconnecter ?', [ + Alert.alert('Déconnexion', 'Voulez-vous vous déconnecter ?', [ { text: 'Annuler', style: 'cancel' }, { - text: 'Se deconnecter', + text: 'Se déconnecter', style: 'destructive', onPress: async () => { await logout(); @@ -44,14 +44,14 @@ export default function ProfileScreen() { - + Application - + @@ -59,7 +59,7 @@ export default function ProfileScreen() { {/* Logout */} - Se deconnecter + Se déconnecter CESIZen v1.0.0 diff --git a/mobile/app/auth/login.tsx b/mobile/app/auth/login.tsx index 8d64c70..c9aaf92 100644 --- a/mobile/app/auth/login.tsx +++ b/mobile/app/auth/login.tsx @@ -35,7 +35,7 @@ export default function LoginScreen() { CESIZen - Votre compagnon de sante mentale + Votre compagnon de santé mentale diff --git a/mobile/components/EmotionPicker.tsx b/mobile/components/EmotionPicker.tsx index 7a071a5..d6bcc53 100644 --- a/mobile/components/EmotionPicker.tsx +++ b/mobile/components/EmotionPicker.tsx @@ -25,10 +25,10 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) { return ( setSelectedParent(null)}> - ← Retour aux emotions + ← Retour aux émotions - Precisez votre emotion de{' '} + Précisez votre émotion de{' '} {selectedParent.nom} : diff --git a/mobile/components/EmotionPieChart.tsx b/mobile/components/EmotionPieChart.tsx new file mode 100644 index 0000000..c98e03b --- /dev/null +++ b/mobile/components/EmotionPieChart.tsx @@ -0,0 +1,129 @@ +import { View, Text, StyleSheet } from 'react-native'; +import Svg, { Circle, G, Text as SvgText } from 'react-native-svg'; +import { Colors } from '@/lib/colors'; + +export interface EmotionPieSlice { + nom: string; + couleur: string; + count: number; + pourcentage: number; +} + +interface EmotionPieChartProps { + data: EmotionPieSlice[]; + /** Diamètre total du graphique en pixels. */ + size?: number; + /** Épaisseur de l'anneau (donut) en pixels. */ + strokeWidth?: number; +} + +/** + * Camembert (donut) de répartition des émotions, équivalent mobile du + * graphique Recharts du web. Construit avec react-native-svg : chaque part + * est un arc de cercle tracé via le stroke-dasharray d'un . + */ +export default function EmotionPieChart({ + data, + size = 200, + strokeWidth = 38, +}: EmotionPieChartProps) { + const total = data.reduce((sum, item) => sum + item.count, 0); + + if (total === 0) { + return null; + } + + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const center = size / 2; + + // Décalage cumulé pour positionner chaque arc à la suite du précédent. + let offsetAcc = 0; + const segments = data.map((item) => { + const fraction = item.count / total; + const dash = fraction * circumference; + const segment = { + ...item, + dash, + gap: circumference - dash, + rotation: (offsetAcc / circumference) * 360, + }; + offsetAcc += dash; + return segment; + }); + + const topPct = Math.round((data[0]?.count / total) * 100); + + return ( + + + {/* Anneau de fond pour les arrondis */} + + + {segments.map((seg) => ( + + ))} + + {/* Centre du donut : part dominante */} + + {`${topPct}%`} + + + {data[0]?.nom ?? ''} + + + + {/* Légende */} + + {data.map((item) => ( + + + + {item.nom} + + {item.pourcentage}% + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { alignItems: 'center', marginBottom: 20 }, + legend: { alignSelf: 'stretch', marginTop: 20, gap: 8 }, + legendRow: { flexDirection: 'row', alignItems: 'center' }, + legendDot: { width: 12, height: 12, borderRadius: 6, marginRight: 10 }, + legendName: { flex: 1, fontSize: 14, color: Colors.black }, + legendPct: { fontSize: 14, fontWeight: '700', color: Colors.gray[600] }, +}); diff --git a/mobile/lib/colors.ts b/mobile/lib/colors.ts index daa1a9e..922407d 100644 --- a/mobile/lib/colors.ts +++ b/mobile/lib/colors.ts @@ -6,9 +6,9 @@ export const Colors = { primary: '#000091', primaryDark: '#1212FF', // bleu survol / accent blueFranceSun: '#6A6AF4', - // Rouge Marianne — accent institutionnel / alertes - secondary: '#000091', // conservé en bleu pour les accents secondaires institutionnels - secondaryDark: '#1212FF', + // Rouge Marianne — accent institutionnel secondaire (DSFR officiel) + secondary: '#E1000F', + secondaryDark: '#C9000E', rougeMarianne: '#E1000F', black: '#161616', // texte principal DSFR white: '#FFFFFF', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index a8c902a..9c85a0b 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -25,6 +25,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", "react-native-worklets": "0.5.1", "zustand": "^5.0.0" }, @@ -3996,7 +3997,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4591,6 +4592,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5155,6 +5162,56 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -5186,7 +5243,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -5354,6 +5411,44 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5378,6 +5473,35 @@ "node": ">=12" } }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -8747,6 +8871,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -9022,6 +9152,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -9721,6 +9863,26 @@ } } }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -9879,6 +10041,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", @@ -11295,7 +11472,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/mobile/package.json b/mobile/package.json index 8f368e9..8ec1ca3 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -33,6 +33,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", "react-native-worklets": "0.5.1", "zustand": "^5.0.0" },