feat: add mobile application structure with emotion picker and API integration
- Added favicon and icon assets for the mobile app. - Implemented EmotionPicker component for selecting emotions with child options. - Created hooks for fetching emotions, feeds, and rapport data. - Established API service for handling requests and authentication. - Integrated Zustand for state management of user authentication. - Defined color palette for the application. - Set up TypeScript types for various entities including User, Emotion, and Feed. - Configured Babel and TypeScript for the mobile project. - Added package.json with necessary dependencies for Expo and React Native.
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# CESIZen - Application de santé mentale
|
||||||
|
|
||||||
|
## Contexte du projet
|
||||||
|
Projet individuel CESI École d'Ingénieurs — Bloc "Concevoir les solutions logicielles" (INFCDAAL1).
|
||||||
|
Commanditaire fictif : Ministère des Solidarités et de la Santé.
|
||||||
|
L'objectif est de proposer une plateforme grand public pour accompagner le quotidien de chacun et l'aider à mieux comprendre les enjeux de sa santé mentale et agir sur son stress.
|
||||||
|
|
||||||
|
## Module choisi
|
||||||
|
**Tracker d'émotions** (module facultatif au choix) + modules obligatoires (Comptes utilisateurs, Informations).
|
||||||
|
|
||||||
|
## Architecture technique
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
- **Backend** : Laravel 11 (PHP 8.2+) — API REST JSON, JWT Auth (`tymon/jwt-auth`)
|
||||||
|
- **Frontend Web** : Next.js 16 (React 19, TypeScript, Tailwind CSS 4, Zustand, TanStack Query, Recharts)
|
||||||
|
- **Mobile natif** : React Native / Expo 53 (Expo Router, Zustand, SecureStore, TanStack Query)
|
||||||
|
- **Base de données** : PostgreSQL 16
|
||||||
|
- **Conteneurisation** : Docker Compose (postgres:5432, backend:8000, frontend:3000)
|
||||||
|
|
||||||
|
### Design Pattern
|
||||||
|
MVC côté backend (Models → Controllers → Routes, Service Layer pour les rapports).
|
||||||
|
Frontend : App Router, Custom Hooks, Component Composition, Zustand Store.
|
||||||
|
|
||||||
|
### Structure du projet
|
||||||
|
```
|
||||||
|
Cesizen/
|
||||||
|
├── backend/ # Laravel 11 API
|
||||||
|
├── frontend/ # Next.js (web)
|
||||||
|
├── mobile/ # React Native Expo (iOS + Android)
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── generate_pdf.py # Génération du cahier de tests (Python/fpdf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fonctionnalités implémentées
|
||||||
|
|
||||||
|
### Modules obligatoires
|
||||||
|
- **Comptes utilisateurs** : inscription, connexion JWT, gestion profil, reset password, admin CRUD, désactivation/suppression, RGPD (soft delete + droit à la suppression)
|
||||||
|
- **Informations** : pages de contenu santé mentale (feeds), CRUD admin, affichage public
|
||||||
|
|
||||||
|
### Module au choix : Tracker d'émotions
|
||||||
|
| Fonctionnalité | Acteur | Web | Mobile |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Afficher le journal de bord | Utilisateur | ✅ | ✅ |
|
||||||
|
| Ajouter une saisie (wizard 3 étapes) | Utilisateur | ✅ | ✅ |
|
||||||
|
| Modifier une saisie | Utilisateur | ✅ (modale) | ✅ (écran edit) |
|
||||||
|
| Supprimer une saisie | Utilisateur | ✅ | ✅ |
|
||||||
|
| Rapports par période (semaine/mois/trimestre/année) | Utilisateur | ✅ (graphiques) | ✅ |
|
||||||
|
| Configurer les émotions (2 niveaux) | Admin | ✅ | N/A |
|
||||||
|
|
||||||
|
### Hiérarchie des émotions (seed)
|
||||||
|
7 émotions de base (niveau 1) avec 31 sous-émotions (niveau 2) :
|
||||||
|
Joie, Tristesse, Colère, Peur, Dégoût, Surprise, Amour.
|
||||||
|
|
||||||
|
## Grille de priorisation des besoins
|
||||||
|
Critères utilisés : Complexité (0-3), Valeur ajoutée Ministère (0-3), Valeur ajoutée Utilisateur (0-3), Nécessité (0-2), Interdépendance (0-2). Total sur 15.
|
||||||
|
|
||||||
|
Top priorités :
|
||||||
|
- Diagnostic de stress / Journal émotions / Rapports d'émotions (10/15)
|
||||||
|
- Cryptage données / Inscription / Cohérence cardiaque / Activités détente / Ajout tracker (9/15)
|
||||||
|
- Conformité RGPD (8/15)
|
||||||
|
|
||||||
|
## Évaluation — Barème (/34 → note lettre)
|
||||||
|
| Domaine | Livrable | Pts |
|
||||||
|
|---|---|---|
|
||||||
|
| Recueil du besoin | Reformulation (contexte, enjeux, contraintes) | 6 |
|
||||||
|
| | Identification des besoins logiciels | 3 |
|
||||||
|
| | Critères de priorités et pondération | 2 |
|
||||||
|
| Analyse des besoins | Complétude du CdC, contraintes, opportunités | 4 |
|
||||||
|
| | Réponse fonctionnelle adaptée et originale | 4 |
|
||||||
|
| | Schématisation et reformulation visuelle | 2 |
|
||||||
|
| Modélisation | MCD (Merise/UML) | 3 |
|
||||||
|
| | Conception technique MVC | 2 |
|
||||||
|
| | Spécifications fonctionnelles et techniques | 4 |
|
||||||
|
| | Données personnelles et sensibles | 1 |
|
||||||
|
| Dossier et soutenance | Qualité dossier + oral (20 min + 10 Q/R) | 3 |
|
||||||
|
|
||||||
|
Conversion : A (27-34) | B (20-26,99) | C (14-19,99) | D (0-13,99)
|
||||||
|
|
||||||
|
## Livrables attendus
|
||||||
|
1. **Cahier des charges** (15-20 pages) : contexte, parties prenantes, besoins reformulés, priorisation, contraintes
|
||||||
|
2. **Spécifications fonctionnelles et techniques** : UML, analyse détaillée (2 obligatoires + 1 au choix), MCD, MVC, RGPD
|
||||||
|
3. **Soutenance orale** : 20 min + 10 min Q/R
|
||||||
|
|
||||||
|
## Acteurs du système
|
||||||
|
- **Visiteur anonyme** : consultation informations, accès public
|
||||||
|
- **Utilisateur connecté** : tracker émotions, journal, rapports, profil
|
||||||
|
- **Administrateur** : gestion utilisateurs, contenus, émotions
|
||||||
|
|
||||||
|
## Conventions de code
|
||||||
|
- Backend : PHP PSR-12, noms en français (utilisateur, saisie_tracker, émotion)
|
||||||
|
- Frontend : TypeScript strict, composants fonctionnels, hooks customs
|
||||||
|
- Mobile : même conventions que le frontend web
|
||||||
|
- Langue de l'interface : français
|
||||||
|
- Langue de l'instruction CLAUDE.md globale : toujours répondre en français
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && php artisan serve # API sur :8000
|
||||||
|
cd backend && php artisan migrate:fresh --seed # Reset + seed DB
|
||||||
|
|
||||||
|
# Frontend Web
|
||||||
|
cd frontend && npm run dev # Dev server sur :3000
|
||||||
|
|
||||||
|
# Mobile
|
||||||
|
cd mobile && npm install && npx expo start # Expo dev server
|
||||||
|
|
||||||
|
# Docker (tout lancer)
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
unzip \
|
unzip \
|
||||||
git \
|
git \
|
||||||
&& docker-php-ext-install pdo pdo_pgsql bcmath \
|
&& docker-php-ext-install pdo pdo_pgsql bcmath \
|
||||||
|
&& pecl install redis && docker-php-ext-enable redis \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Composer
|
# Composer
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ class AdminUtilisateurController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('nom', 'ilike', "%{$search}%")
|
$q->where('nom', 'like', "%{$search}%")
|
||||||
->orWhere('prenom', 'ilike', "%{$search}%")
|
->orWhere('prenom', 'like', "%{$search}%")
|
||||||
->orWhere('email', 'ilike', "%{$search}%");
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\UpdatePasswordRequest;
|
||||||
use App\Http\Requests\UpdateProfilRequest;
|
use App\Http\Requests\UpdateProfilRequest;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class ProfilController extends Controller
|
class ProfilController extends Controller
|
||||||
@@ -29,13 +29,8 @@ class ProfilController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePassword(Request $request): JsonResponse
|
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
|
||||||
'ancien_mot_de_passe' => 'required|string',
|
|
||||||
'password' => 'required|string|min:8|confirmed',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$utilisateur = auth()->user();
|
$utilisateur = auth()->user();
|
||||||
|
|
||||||
if (!Hash::check($request->ancien_mot_de_passe, $utilisateur->password)) {
|
if (!Hash::check($request->ancien_mot_de_passe, $utilisateur->password)) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class RoleMiddleware
|
|||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (!$user || !in_array($user->role->nom, $roles)) {
|
if (!$user || !$user->role || !in_array($user->role->nom, $roles)) {
|
||||||
return response()->json(['message' => 'Accès non autorisé'], 403);
|
return response()->json(['message' => 'Accès non autorisé'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class StoreFeedRequest extends FormRequest
|
|||||||
$rules = [
|
$rules = [
|
||||||
'titre' => 'required|string|max:255',
|
'titre' => 'required|string|max:255',
|
||||||
'contenu' => 'required|string',
|
'contenu' => 'required|string',
|
||||||
'image_url' => 'nullable|string|max:500',
|
'image_url' => 'nullable|url|max:500',
|
||||||
'est_publie' => 'boolean',
|
'est_publie' => 'boolean',
|
||||||
'ordre' => 'integer|min:0',
|
'ordre' => 'integer|min:0',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class StoreSaisieRequest extends FormRequest
|
|||||||
'emotion_id' => 'required|exists:emotions,id',
|
'emotion_id' => 'required|exists:emotions,id',
|
||||||
'intensite' => 'required|integer|between:1,10',
|
'intensite' => 'required|integer|between:1,10',
|
||||||
'note' => 'nullable|string|max:2000',
|
'note' => 'nullable|string|max:2000',
|
||||||
'date_saisie' => 'required|date',
|
'date_saisie' => 'required|date_format:Y-m-d',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdatePasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ancien_mot_de_passe' => 'required|string',
|
||||||
|
'password' => 'required|string|min:8|confirmed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ancien_mot_de_passe.required' => 'L\'ancien mot de passe est obligatoire.',
|
||||||
|
'password.required' => 'Le nouveau mot de passe est obligatoire.',
|
||||||
|
'password.min' => 'Le nouveau mot de passe doit contenir au moins 8 caractères.',
|
||||||
|
'password.confirmed' => 'La confirmation du mot de passe ne correspond pas.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ return new class extends Migration
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('tracker_id')->constrained('trackers')->onDelete('cascade');
|
$table->foreignId('tracker_id')->constrained('trackers')->onDelete('cascade');
|
||||||
$table->foreignId('emotion_id')->constrained('emotions');
|
$table->foreignId('emotion_id')->constrained('emotions');
|
||||||
$table->tinyInteger('intensite');
|
$table->unsignedTinyInteger('intensite');
|
||||||
$table->text('note')->nullable();
|
$table->text('note')->nullable();
|
||||||
$table->dateTime('date_saisie');
|
$table->dateTime('date_saisie');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ return new class extends Migration
|
|||||||
$table->longText('contenu');
|
$table->longText('contenu');
|
||||||
$table->string('image_url')->nullable();
|
$table->string('image_url')->nullable();
|
||||||
$table->boolean('est_publie')->default(false);
|
$table->boolean('est_publie')->default(false);
|
||||||
$table->foreignId('auteur_id')->constrained('utilisateurs');
|
$table->foreignId('auteur_id')->constrained('utilisateurs')->onDelete('cascade');
|
||||||
$table->integer('ordre')->default(0);
|
$table->integer('ordre')->default(0);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c max_connections=200
|
||||||
|
-c shared_buffers=256MB
|
||||||
|
-c effective_cache_size=768MB
|
||||||
|
-c work_mem=4MB
|
||||||
|
-c maintenance_work_mem=128MB
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: cesizen-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
@@ -24,6 +45,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
|
APP_NAME: CESIZen
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
APP_DEBUG: "true"
|
APP_DEBUG: "true"
|
||||||
APP_KEY: base64:UMxXo/+o7v1n6E+kimssgw8L2lQ2NQGMuQffn+Vh7lc=
|
APP_KEY: base64:UMxXo/+o7v1n6E+kimssgw8L2lQ2NQGMuQffn+Vh7lc=
|
||||||
@@ -36,11 +58,18 @@ services:
|
|||||||
DB_PASSWORD: cesizen_secret
|
DB_PASSWORD: cesizen_secret
|
||||||
JWT_SECRET: w89wy05gyYCjGGBXyRHqthKEou5e40er2rdjg69j0mUCRL5ljescaC1CiMl8awtg
|
JWT_SECRET: w89wy05gyYCjGGBXyRHqthKEou5e40er2rdjg69j0mUCRL5ljescaC1CiMl8awtg
|
||||||
FRONTEND_URL: http://localhost:3000
|
FRONTEND_URL: http://localhost:3000
|
||||||
|
CACHE_STORE: redis
|
||||||
|
SESSION_DRIVER: redis
|
||||||
|
QUEUE_CONNECTION: redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
@@ -58,3 +87,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Edit2, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Feed } from '@/types';
|
import type { Feed } from '@/types';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
@@ -69,8 +69,9 @@ export default function AdminContenusPage() {
|
|||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
await fetchFeeds();
|
await fetchFeeds();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur');
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Users, FileText, Heart, TrendingUp } from 'lucide-react';
|
import { Users, FileText, Heart } from 'lucide-react';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Edit2, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Emotion } from '@/types';
|
import type { Emotion } from '@/types';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
@@ -81,8 +81,9 @@ export default function AdminEmotionsPage() {
|
|||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
await fetchEmotions();
|
await fetchEmotions();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur');
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export default function AdminUtilisateursPage() {
|
|||||||
setNewUser({ nom: '', prenom: '', email: '', password: '', password_confirmation: '', role_id: 2 });
|
setNewUser({ nom: '', prenom: '', email: '', password: '', password_confirmation: '', role_id: 2 });
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
toast.success('Utilisateur créé');
|
toast.success('Utilisateur créé');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur lors de la création');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur lors de la création');
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export default function ConnexionPage() {
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
toast.success('Connexion réussie !');
|
toast.success('Connexion réussie !');
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = error?.response?.data?.message || 'Identifiants incorrects';
|
const axiosError = error as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
const message = axiosError.response?.data?.message || 'Identifiants incorrects';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -83,6 +84,7 @@ export default function ConnexionPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export default function InscriptionPage() {
|
|||||||
await register(form);
|
await register(form);
|
||||||
toast.success('Inscription réussie ! Bienvenue sur CESIZen.');
|
toast.success('Inscription réussie ! Bienvenue sur CESIZen.');
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const data = error?.response?.data;
|
const axiosError = error as import('axios').AxiosError<{ message?: string; errors?: Record<string, string[]> }>;
|
||||||
|
const data = axiosError.response?.data;
|
||||||
if (data?.errors) {
|
if (data?.errors) {
|
||||||
setErrors(data.errors);
|
setErrors(data.errors);
|
||||||
} else {
|
} else {
|
||||||
@@ -143,6 +144,7 @@ export default function InscriptionPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,8 +183,8 @@ export default function InscriptionPage() {
|
|||||||
/>
|
/>
|
||||||
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400">
|
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
J'accepte le traitement de mes données personnelles conformément à la{' '}
|
J'accepte le traitement de mes données personnelles conformément à la{' '}
|
||||||
<Link href="/informations" className="text-malachite-600 dark:text-malachite-400 hover:underline">
|
<Link href="/politique-de-confidentialite" className="text-malachite-600 dark:text-malachite-400 hover:underline">
|
||||||
politique de confidentialite
|
politique de confidentialité
|
||||||
</Link>
|
</Link>
|
||||||
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
|
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Heart, BarChart3, Plus, BookOpen, TrendingUp } from 'lucide-react';
|
import { Heart, BarChart3, Plus, BookOpen } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useSaisies } from '@/hooks/useTracker';
|
import { useSaisies } from '@/hooks/useTracker';
|
||||||
import { useRapport } from '@/hooks/useRapport';
|
import { useRapport } from '@/hooks/useRapport';
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, Filter, Calendar } from 'lucide-react';
|
import { Plus, Filter } from 'lucide-react';
|
||||||
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||||
import { useEmotions } from '@/hooks/useEmotions';
|
import { useEmotions } from '@/hooks/useEmotions';
|
||||||
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
||||||
|
import EditSaisieModal from '@/components/features/EditSaisieModal';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Skeleton from '@/components/ui/Skeleton';
|
import Skeleton from '@/components/ui/Skeleton';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import type { SaisieTracker } from '@/types';
|
||||||
|
|
||||||
export default function JournalPage() {
|
export default function JournalPage() {
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
@@ -18,6 +20,7 @@ export default function JournalPage() {
|
|||||||
date_fin?: string;
|
date_fin?: string;
|
||||||
emotion_id?: number;
|
emotion_id?: number;
|
||||||
}>({});
|
}>({});
|
||||||
|
const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null);
|
||||||
|
|
||||||
const { data: saisies, isLoading } = useSaisies(filters);
|
const { data: saisies, isLoading } = useSaisies(filters);
|
||||||
const { data: emotions } = useEmotions();
|
const { data: emotions } = useEmotions();
|
||||||
@@ -134,9 +137,17 @@ export default function JournalPage() {
|
|||||||
) : (
|
) : (
|
||||||
<TrackerTimeline
|
<TrackerTimeline
|
||||||
saisies={saisies || []}
|
saisies={saisies || []}
|
||||||
|
onEdit={(saisie) => setEditingSaisie(saisie)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modale d'édition */}
|
||||||
|
<EditSaisieModal
|
||||||
|
saisie={editingSaisie}
|
||||||
|
isOpen={!!editingSaisie}
|
||||||
|
onClose={() => setEditingSaisie(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ export default function ProfilPage() {
|
|||||||
await fetchUser();
|
await fetchUser();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
toast.success('Profil mis à jour');
|
toast.success('Profil mis à jour');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur lors de la mise à jour');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur lors de la mise à jour');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,8 +53,9 @@ export default function ProfilPage() {
|
|||||||
setChangingPassword(false);
|
setChangingPassword(false);
|
||||||
setPasswordForm({ ancien_mot_de_passe: '', password: '', password_confirmation: '' });
|
setPasswordForm({ ancien_mot_de_passe: '', password: '', password_confirmation: '' });
|
||||||
toast.success('Mot de passe modifié');
|
toast.success('Mot de passe modifié');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur lors du changement');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur lors du changement');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -65,8 +67,9 @@ export default function ProfilPage() {
|
|||||||
await api.delete('/profil');
|
await api.delete('/profil');
|
||||||
toast.success('Compte supprimé');
|
toast.success('Compte supprimé');
|
||||||
logout();
|
logout();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.message || 'Erreur lors de la suppression');
|
const axiosError = err as import('axios').AxiosError<{ message?: string }>;
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Erreur lors de la suppression');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { Phone } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ContactsUrgencePage() {
|
||||||
|
const contacts = [
|
||||||
|
{
|
||||||
|
name: 'Numéro national de prévention du suicide',
|
||||||
|
number: '3114',
|
||||||
|
description:
|
||||||
|
'Numéro national disponible 24h/24 et 7j/7. Appel gratuit et confidentiel pour les personnes en détresse psychologique et leur entourage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SOS Amitié',
|
||||||
|
number: '09 72 39 40 50',
|
||||||
|
description:
|
||||||
|
'Écoute et soutien pour les personnes en souffrance, solitude ou détresse morale. Service disponible 24h/24.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fil Santé Jeunes',
|
||||||
|
number: '0 800 235 236',
|
||||||
|
description:
|
||||||
|
'Ligne d\u2019écoute gratuite et anonyme pour les jeunes de 12 à 25 ans. Du lundi au dimanche de 9h à 23h.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SAMU',
|
||||||
|
number: '15',
|
||||||
|
description:
|
||||||
|
'Service d\u2019aide médicale urgente. En cas d\u2019urgence vitale, appelez immédiatement le 15 ou le 112.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SOS Médecins',
|
||||||
|
number: '3624',
|
||||||
|
description:
|
||||||
|
'Service de médecins disponible pour des consultations à domicile, 24h/24 et 7j/7.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||||
|
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Contacts d'urgence
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400 mb-8">
|
||||||
|
Si vous ou un proche traversez une période difficile, n'hésitez pas à
|
||||||
|
contacter l'un de ces services. Vous n'êtes pas seul(e).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{contacts.map((contact) => (
|
||||||
|
<Card key={contact.number}>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-50 dark:bg-red-950/50 flex items-center justify-center">
|
||||||
|
<Phone size={20} className="text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
{contact.name}
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href={`tel:${contact.number.replace(/\s/g, '')}`}
|
||||||
|
className="inline-block text-xl font-bold text-malachite-600 dark:text-malachite-400 mb-2 hover:underline"
|
||||||
|
>
|
||||||
|
{contact.number}
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
|
{contact.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-8 bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400 text-center leading-relaxed">
|
||||||
|
<strong>En cas d'urgence vitale</strong>, appelez immédiatement le{' '}
|
||||||
|
<strong>15</strong> (SAMU) ou le <strong>112</strong> (numéro d'urgence
|
||||||
|
européen).
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { ArrowLeft, Calendar, User } from 'lucide-react';
|
import { ArrowLeft, Calendar, User } from 'lucide-react';
|
||||||
import { useFeed } from '@/hooks/useFeeds';
|
import { useFeed } from '@/hooks/useFeeds';
|
||||||
import Skeleton from '@/components/ui/Skeleton';
|
import Skeleton from '@/components/ui/Skeleton';
|
||||||
@@ -52,10 +53,13 @@ export default function ArticlePage() {
|
|||||||
|
|
||||||
{feed.image_url && (
|
{feed.image_url && (
|
||||||
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8">
|
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8">
|
||||||
<img
|
<Image
|
||||||
src={feed.image_url}
|
src={feed.image_url}
|
||||||
alt={feed.titre}
|
alt={feed.titre}
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { BookOpen, Calendar } from 'lucide-react';
|
import { BookOpen, Calendar } from 'lucide-react';
|
||||||
import { useFeeds } from '@/hooks/useFeeds';
|
import { useFeeds } from '@/hooks/useFeeds';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
@@ -39,10 +40,13 @@ export default function InformationsPage() {
|
|||||||
<Card hover className="h-full">
|
<Card hover className="h-full">
|
||||||
{feed.image_url && (
|
{feed.image_url && (
|
||||||
<div className="relative w-full h-48 mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800">
|
<div className="relative w-full h-48 mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||||
<img
|
<Image
|
||||||
src={feed.image_url}
|
src={feed.image_url}
|
||||||
alt={feed.titre}
|
alt={feed.titre}
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
|
||||||
|
export default function MentionsLegalesPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||||
|
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8">
|
||||||
|
Mentions légales
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Éditeur de l'application
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
CESIZen est une application développée dans le cadre d'un projet
|
||||||
|
pédagogique au sein de{' '}
|
||||||
|
<strong>CESI École d'Ingénieurs</strong>. Cette application est
|
||||||
|
réalisée à but éducatif et n'a pas de vocation commerciale.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Hébergement
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
L'application est hébergée dans le cadre de l'infrastructure
|
||||||
|
pédagogique de CESI. Les données sont stockées sur des serveurs
|
||||||
|
sécurisés situés en France.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Propriété intellectuelle
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
L'ensemble des contenus présents sur CESIZen (textes, images,
|
||||||
|
graphismes, logo, icônes) sont la propriété exclusive de leurs auteurs
|
||||||
|
respectifs et sont protégés par les lois françaises et internationales
|
||||||
|
relatives à la propriété intellectuelle.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Responsabilité
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
CESIZen est un outil de suivi du bien-être et ne se substitue en aucun
|
||||||
|
cas à un avis médical professionnel. En cas de détresse psychologique,
|
||||||
|
veuillez contacter les services d'urgence ou un professionnel de
|
||||||
|
santé.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Contact
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
Pour toute question relative aux mentions légales, vous pouvez nous
|
||||||
|
contacter via l'adresse suivante :{' '}
|
||||||
|
<span className="text-malachite-600 dark:text-malachite-400">
|
||||||
|
contact@cesizen.fr
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
|
||||||
|
export default function PolitiqueConfidentialitePage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-12">
|
||||||
|
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-8">
|
||||||
|
Politique de confidentialité
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Conformité RGPD
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
CESIZen s'engage à respecter le Règlement Général sur la Protection
|
||||||
|
des Données (RGPD) et la loi Informatique et Libertés. La protection
|
||||||
|
de vos données personnelles est une priorité.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Données collectées
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
|
||||||
|
Dans le cadre de l'utilisation de CESIZen, les données suivantes
|
||||||
|
sont collectées :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
<li>Adresse e-mail</li>
|
||||||
|
<li>Nom et prénom</li>
|
||||||
|
<li>Données relatives aux émotions enregistrées</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Finalité du traitement
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
Ces données sont collectées uniquement dans le but de fournir un suivi
|
||||||
|
personnalisé du bien-être émotionnel. Elles ne sont ni vendues, ni
|
||||||
|
partagées avec des tiers à des fins commerciales.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Durée de conservation
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
Les données personnelles sont conservées pendant toute la durée
|
||||||
|
d'utilisation du compte. En cas de suppression du compte, les
|
||||||
|
données sont effacées dans un délai de 30 jours.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Sécurité des données
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
Les données sont chiffrées en transit (HTTPS) et au repos. Les mots de
|
||||||
|
passe sont hachés avec des algorithmes sécurisés. L'accès aux
|
||||||
|
données est strictement limité aux personnes habilitées.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Vos droits
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
|
||||||
|
Conformément au RGPD, vous disposez des droits suivants :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Droit d'accès :</strong> obtenir une copie de vos
|
||||||
|
données personnelles
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Droit de rectification :</strong> corriger des données
|
||||||
|
inexactes ou incomplètes
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Droit de suppression :</strong> demander l'effacement
|
||||||
|
de vos données personnelles
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Droit à la portabilité :</strong> récupérer vos données dans
|
||||||
|
un format structuré
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-3">
|
||||||
|
Pour exercer ces droits, contactez-nous à l'adresse :{' '}
|
||||||
|
<span className="text-malachite-600 dark:text-malachite-400">
|
||||||
|
contact@cesizen.fr
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { SaisieTracker } from '@/types';
|
||||||
|
import { useUpdateSaisie } from '@/hooks/useTracker';
|
||||||
|
import EmotionPicker from '@/components/features/EmotionPicker';
|
||||||
|
import Slider from '@/components/ui/Slider';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface EditSaisieModalProps {
|
||||||
|
saisie: SaisieTracker | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieModalProps) {
|
||||||
|
const updateSaisie = useUpdateSaisie();
|
||||||
|
const [emotionId, setEmotionId] = useState<number | undefined>();
|
||||||
|
const [intensite, setIntensite] = useState(5);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [dateSaisie, setDateSaisie] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saisie) {
|
||||||
|
setEmotionId(saisie.emotion_id);
|
||||||
|
setIntensite(saisie.intensite);
|
||||||
|
setNote(saisie.note || '');
|
||||||
|
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
||||||
|
}
|
||||||
|
}, [saisie]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!saisie || !emotionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSaisie.mutateAsync({
|
||||||
|
id: saisie.id,
|
||||||
|
emotion_id: emotionId,
|
||||||
|
intensite,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
date_saisie: dateSaisie,
|
||||||
|
});
|
||||||
|
toast.success('Saisie modifiée !');
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error('Erreur lors de la modification');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Modifier la saisie" size="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Émotion */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Émotion
|
||||||
|
</label>
|
||||||
|
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Intensité */}
|
||||||
|
<div>
|
||||||
|
<Slider
|
||||||
|
value={intensite}
|
||||||
|
onChange={setIntensite}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
label="Intensité de l'émotion"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>Faible</span>
|
||||||
|
<span>Modérée</span>
|
||||||
|
<span>Forte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||||
|
Date de la saisie
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateSaisie}
|
||||||
|
onChange={(e) => setDateSaisie(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||||
|
Note (optionnel)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Décrivez ce que vous ressentez..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 placeholder:text-gray-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={updateSaisie.isPending}
|
||||||
|
disabled={!emotionId}
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ interface AuthState {
|
|||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "CESIZen",
|
||||||
|
"slug": "cesizen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"scheme": "cesizen",
|
||||||
|
"splash": {
|
||||||
|
"backgroundColor": "#fce117",
|
||||||
|
"resizeMode": "contain"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.cesi.cesizen"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#fce117"
|
||||||
|
},
|
||||||
|
"package": "com.cesi.cesizen"
|
||||||
|
},
|
||||||
|
"plugins": ["expo-router", "expo-secure-store", "expo-font"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: Colors.secondary,
|
||||||
|
tabBarInactiveTintColor: Colors.gray[400],
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
borderTopColor: Colors.gray[200],
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
height: 65,
|
||||||
|
},
|
||||||
|
headerStyle: { backgroundColor: Colors.white },
|
||||||
|
headerTitleStyle: { fontWeight: '700', color: Colors.black },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Accueil',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="journal"
|
||||||
|
options={{
|
||||||
|
title: 'Journal',
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="book" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="info"
|
||||||
|
options={{
|
||||||
|
title: 'Infos',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="information-circle" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: 'Profil',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { useRapport } from '@/hooks/useRapport';
|
||||||
|
import { useSaisies } from '@/hooks/useTracker';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { data: rapport } = useRapport('month');
|
||||||
|
const { data: saisies } = useSaisies();
|
||||||
|
|
||||||
|
const recentSaisies = (saisies || []).slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.greeting}>Bonjour, {user?.prenom} 👋</Text>
|
||||||
|
<Text style={styles.subtitle}>Comment allez-vous aujourd'hui ?</Text>
|
||||||
|
|
||||||
|
{/* Actions rapides */}
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/new')}>
|
||||||
|
<View style={[styles.actionIcon, { backgroundColor: `${Colors.primary}30` }]}>
|
||||||
|
<Ionicons name="add-circle" size={28} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionText}>Nouvelle saisie</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/reports')}>
|
||||||
|
<View style={[styles.actionIcon, { backgroundColor: `${Colors.secondary}30` }]}>
|
||||||
|
<Ionicons name="bar-chart" size={28} color={Colors.secondary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionText}>Mes rapports</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stats du mois */}
|
||||||
|
{rapport && (
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>Ce mois-ci</Text>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
||||||
|
<Text style={styles.statLabel}>Saisies</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
|
||||||
|
<Text style={styles.statLabel}>Intensite moy.</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
||||||
|
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dernieres saisies */}
|
||||||
|
{recentSaisies.length > 0 && (
|
||||||
|
<View style={styles.recentContainer}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Dernieres emotions</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(tabs)/journal')}>
|
||||||
|
<Text style={styles.seeAll}>Tout voir</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{recentSaisies.map((saisie) => (
|
||||||
|
<View key={saisie.id} style={styles.saisieItem}>
|
||||||
|
<View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}>
|
||||||
|
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.saisieContent}>
|
||||||
|
<Text style={styles.saisieEmotion}>{saisie.emotion?.nom}</Text>
|
||||||
|
<Text style={styles.saisieDate}>
|
||||||
|
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensite: {saisie.intensite}/10
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
content: { padding: 20 },
|
||||||
|
greeting: { fontSize: 28, fontWeight: '800', color: Colors.black, marginTop: 8 },
|
||||||
|
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 4, marginBottom: 24 },
|
||||||
|
actionsRow: { flexDirection: 'row', gap: 12, marginBottom: 24 },
|
||||||
|
actionCard: {
|
||||||
|
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 16, padding: 20,
|
||||||
|
alignItems: 'center', gap: 12, borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
actionIcon: { width: 56, height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
actionText: { fontSize: 14, fontWeight: '600', color: Colors.gray[700] },
|
||||||
|
statsContainer: { marginBottom: 24 },
|
||||||
|
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 12 },
|
||||||
|
statsRow: { flexDirection: 'row', gap: 12 },
|
||||||
|
statCard: {
|
||||||
|
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 16, padding: 16,
|
||||||
|
alignItems: 'center', borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
statValue: { fontSize: 24, fontWeight: '800', color: Colors.black },
|
||||||
|
statLabel: { fontSize: 12, color: Colors.gray[500], marginTop: 4 },
|
||||||
|
recentContainer: { marginBottom: 24 },
|
||||||
|
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||||
|
seeAll: { fontSize: 14, fontWeight: '600', color: Colors.secondary },
|
||||||
|
saisieItem: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14,
|
||||||
|
backgroundColor: Colors.gray[50], borderRadius: 14, marginBottom: 8,
|
||||||
|
borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
emotionIcon: { width: 44, height: 44, borderRadius: 12, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
emotionEmoji: { fontSize: 22 },
|
||||||
|
saisieContent: { flex: 1 },
|
||||||
|
saisieEmotion: { fontSize: 15, fontWeight: '600', color: Colors.black },
|
||||||
|
saisieDate: { fontSize: 13, color: Colors.gray[500], marginTop: 2 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useFeeds } from '@/hooks/useFeeds';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function InfoScreen() {
|
||||||
|
const { data: feeds, isLoading } = useFeeds();
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: any }) => (
|
||||||
|
<TouchableOpacity style={styles.card}>
|
||||||
|
<View style={styles.cardIcon}>
|
||||||
|
<Ionicons name="document-text" size={24} color={Colors.secondary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text style={styles.cardTitle}>{item.titre}</Text>
|
||||||
|
<Text style={styles.cardExcerpt} numberOfLines={2}>
|
||||||
|
{item.contenu?.replace(/<[^>]*>/g, '').substring(0, 120)}...
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cardDate}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString('fr-FR')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={Colors.gray[300]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<FlatList
|
||||||
|
data={feeds || []}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Sante mentale</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-etre</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
isLoading ? (
|
||||||
|
<View style={styles.loading}><Text style={styles.loadingText}>Chargement...</Text></View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="newspaper-outline" size={48} color={Colors.gray[300]} />
|
||||||
|
<Text style={styles.emptyText}>Aucun article disponible</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
listContent: { padding: 16 },
|
||||||
|
header: { marginBottom: 20 },
|
||||||
|
headerTitle: { fontSize: 14, fontWeight: '700', color: Colors.secondary, textTransform: 'uppercase', letterSpacing: 1 },
|
||||||
|
headerSubtitle: { fontSize: 15, color: Colors.gray[500], marginTop: 4 },
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16,
|
||||||
|
backgroundColor: Colors.gray[50], borderRadius: 16, marginBottom: 10,
|
||||||
|
borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
cardIcon: { width: 48, height: 48, borderRadius: 14, backgroundColor: `${Colors.secondary}15`, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
cardContent: { flex: 1 },
|
||||||
|
cardTitle: { fontSize: 15, fontWeight: '700', color: Colors.black, marginBottom: 4 },
|
||||||
|
cardExcerpt: { fontSize: 13, color: Colors.gray[500], lineHeight: 18 },
|
||||||
|
cardDate: { fontSize: 11, color: Colors.gray[400], marginTop: 4 },
|
||||||
|
loading: { padding: 40, alignItems: 'center' },
|
||||||
|
loadingText: { color: Colors.gray[400] },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
|
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function JournalLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: Colors.white },
|
||||||
|
headerTitleStyle: { fontWeight: '700', color: Colors.black },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Mon journal' }} />
|
||||||
|
<Stack.Screen name="new" options={{ title: 'Nouvelle saisie', presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="edit" options={{ title: 'Modifier la saisie', presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="reports" options={{ title: 'Mes rapports' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert } from 'react-native';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSaisies, useUpdateSaisie } from '@/hooks/useTracker';
|
||||||
|
import EmotionPicker from '@/components/EmotionPicker';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function EditEntryScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: saisies } = useSaisies();
|
||||||
|
const updateSaisie = useUpdateSaisie();
|
||||||
|
|
||||||
|
const saisie = saisies?.find((s) => s.id === Number(id));
|
||||||
|
|
||||||
|
const [emotionId, setEmotionId] = useState<number | undefined>();
|
||||||
|
const [intensite, setIntensite] = useState(5);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [dateSaisie, setDateSaisie] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saisie) {
|
||||||
|
setEmotionId(saisie.emotion_id);
|
||||||
|
setIntensite(saisie.intensite);
|
||||||
|
setNote(saisie.note || '');
|
||||||
|
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
||||||
|
}
|
||||||
|
}, [saisie]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!emotionId || !id) return;
|
||||||
|
try {
|
||||||
|
await updateSaisie.mutateAsync({
|
||||||
|
id: Number(id),
|
||||||
|
emotion_id: emotionId,
|
||||||
|
intensite,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
date_saisie: dateSaisie,
|
||||||
|
});
|
||||||
|
Alert.alert('Succes', 'Saisie modifiee !', [
|
||||||
|
{ text: 'OK', onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erreur', 'Impossible de modifier la saisie');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!saisie) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<Text style={styles.loadingText}>Chargement...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>Modifier la saisie</Text>
|
||||||
|
|
||||||
|
{/* Emotion */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Emotion</Text>
|
||||||
|
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Intensite */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Intensite</Text>
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
<Text style={styles.intensityValue}>{intensite}</Text>
|
||||||
|
<Text style={styles.intensityUnit}>/10</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.sliderButtons}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={v}
|
||||||
|
style={[styles.sliderDot, intensite === v && styles.sliderDotActive]}
|
||||||
|
onPress={() => setIntensite(v)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.sliderDotText, intensite === v && styles.sliderDotTextActive]}>{v}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.sliderLabels}>
|
||||||
|
<Text style={styles.sliderLabel}>Faible</Text>
|
||||||
|
<Text style={styles.sliderLabel}>Forte</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Note (optionnel)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
placeholder="Decrivez ce que vous ressentez..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.cancelBtnText}>Annuler</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitBtn, updateSaisie.isPending && styles.btnDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={updateSaisie.isPending || !emotionId}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark" size={20} color={Colors.black} />
|
||||||
|
<Text style={styles.submitBtnText}>
|
||||||
|
{updateSaisie.isPending ? 'Envoi...' : 'Enregistrer'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
content: { padding: 20, paddingBottom: 40 },
|
||||||
|
loading: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
loadingText: { color: Colors.gray[400], fontSize: 16 },
|
||||||
|
title: { fontSize: 24, fontWeight: '800', color: Colors.black, marginBottom: 24 },
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
label: { fontSize: 15, fontWeight: '700', color: Colors.gray[700], marginBottom: 10 },
|
||||||
|
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: 16 },
|
||||||
|
intensityValue: { fontSize: 48, fontWeight: '800', color: Colors.black },
|
||||||
|
intensityUnit: { fontSize: 20, color: Colors.gray[400], marginLeft: 4 },
|
||||||
|
sliderButtons: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||||
|
sliderDot: {
|
||||||
|
width: 28, height: 28, borderRadius: 14,
|
||||||
|
backgroundColor: Colors.gray[100], justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
sliderDotActive: { backgroundColor: Colors.primary },
|
||||||
|
sliderDotText: { fontSize: 12, fontWeight: '600', color: Colors.gray[500] },
|
||||||
|
sliderDotTextActive: { color: Colors.black },
|
||||||
|
sliderLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||||
|
sliderLabel: { fontSize: 12, color: Colors.gray[400] },
|
||||||
|
textArea: {
|
||||||
|
backgroundColor: Colors.gray[50], borderWidth: 1, borderColor: Colors.gray[200],
|
||||||
|
borderRadius: 16, padding: 16, fontSize: 15, color: Colors.black, minHeight: 100,
|
||||||
|
},
|
||||||
|
actionsRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8, gap: 12 },
|
||||||
|
cancelBtn: {
|
||||||
|
flex: 1, paddingVertical: 16, borderRadius: 14,
|
||||||
|
backgroundColor: Colors.gray[100], alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelBtnText: { fontSize: 15, fontWeight: '600', color: Colors.gray[600] },
|
||||||
|
submitBtn: {
|
||||||
|
flex: 1, flexDirection: 'row', gap: 8, paddingVertical: 16, borderRadius: 14,
|
||||||
|
backgroundColor: Colors.secondary, alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
|
submitBtnText: { fontSize: 15, fontWeight: '700', color: Colors.black },
|
||||||
|
btnDisabled: { opacity: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import type { SaisieTracker } from '@/types';
|
||||||
|
|
||||||
|
export default function JournalScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: saisies, isLoading, refetch } = useSaisies();
|
||||||
|
const deleteSaisie = useDeleteSaisie();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
Alert.alert('Supprimer', 'Supprimer cette saisie ?', [
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Supprimer',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteSaisie.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erreur', 'Impossible de supprimer');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: SaisieTracker }) => (
|
||||||
|
<View style={styles.saisieCard}>
|
||||||
|
<View style={[styles.emotionIcon, { backgroundColor: `${item.emotion?.couleur}20` }]}>
|
||||||
|
<Text style={styles.emotionEmoji}>{item.emotion?.icone || '🔵'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.saisieContent}>
|
||||||
|
<View style={styles.saisieHeader}>
|
||||||
|
<View style={[styles.badge, { backgroundColor: `${item.emotion?.couleur}20` }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: item.emotion?.couleur }]}>{item.emotion?.nom}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.intensityText}>Intensite: {item.intensite}/10</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.intensityBar}>
|
||||||
|
<View style={[styles.intensityFill, { width: `${item.intensite * 10}%`, backgroundColor: item.emotion?.couleur || Colors.primary }]} />
|
||||||
|
</View>
|
||||||
|
{item.note && <Text style={styles.note} numberOfLines={2}>{item.note}</Text>}
|
||||||
|
<Text style={styles.date}>{new Date(item.date_saisie).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={() => router.push({ pathname: '/(tabs)/journal/edit', params: { id: String(item.id) } })}>
|
||||||
|
<Ionicons name="create-outline" size={18} color={Colors.gray[400]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={() => handleDelete(item.id)}>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={Colors.gray[400]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<FlatList
|
||||||
|
data={saisies || []}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="book-outline" size={48} color={Colors.gray[300]} />
|
||||||
|
<Text style={styles.emptyTitle}>Aucune saisie</Text>
|
||||||
|
<Text style={styles.emptyText}>Commencez par enregistrer votre premiere emotion</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={styles.fab} onPress={() => router.push('/(tabs)/journal/new')}>
|
||||||
|
<Ionicons name="add" size={28} color={Colors.black} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
listContent: { padding: 16, paddingBottom: 80 },
|
||||||
|
saisieCard: {
|
||||||
|
flexDirection: 'row', alignItems: 'flex-start', gap: 12, padding: 16,
|
||||||
|
backgroundColor: Colors.gray[50], borderRadius: 16, marginBottom: 10,
|
||||||
|
borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
emotionIcon: { width: 48, height: 48, borderRadius: 14, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
emotionEmoji: { fontSize: 24 },
|
||||||
|
saisieContent: { flex: 1 },
|
||||||
|
saisieHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 },
|
||||||
|
badge: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 8 },
|
||||||
|
badgeText: { fontSize: 13, fontWeight: '600' },
|
||||||
|
intensityText: { fontSize: 12, color: Colors.gray[400] },
|
||||||
|
intensityBar: { height: 4, backgroundColor: Colors.gray[200], borderRadius: 2, marginBottom: 6 },
|
||||||
|
intensityFill: { height: 4, borderRadius: 2 },
|
||||||
|
note: { fontSize: 13, color: Colors.gray[600], marginBottom: 4 },
|
||||||
|
date: { fontSize: 12, color: Colors.gray[400] },
|
||||||
|
actions: { gap: 4 },
|
||||||
|
actionBtn: { padding: 6, borderRadius: 8 },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 80 },
|
||||||
|
emptyTitle: { fontSize: 18, fontWeight: '600', color: Colors.gray[400], marginTop: 12 },
|
||||||
|
emptyText: { fontSize: 14, color: Colors.gray[400], marginTop: 4 },
|
||||||
|
fab: {
|
||||||
|
position: 'absolute', right: 20, bottom: 20,
|
||||||
|
width: 56, height: 56, borderRadius: 28,
|
||||||
|
backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center',
|
||||||
|
shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useCreateSaisie } from '@/hooks/useTracker';
|
||||||
|
import EmotionPicker from '@/components/EmotionPicker';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
export default function NewEntryScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const createSaisie = useCreateSaisie();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [emotionId, setEmotionId] = useState<number | undefined>();
|
||||||
|
const [intensite, setIntensite] = useState(5);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!emotionId) return;
|
||||||
|
try {
|
||||||
|
await createSaisie.mutateAsync({
|
||||||
|
emotion_id: emotionId,
|
||||||
|
intensite,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
date_saisie: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
Alert.alert('Succes', 'Saisie enregistree !', [
|
||||||
|
{ text: 'OK', onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erreur', 'Impossible d\'enregistrer la saisie');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Stepper */}
|
||||||
|
<View style={styles.stepper}>
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<View key={s} style={styles.stepRow}>
|
||||||
|
<View style={[styles.stepCircle, step >= s && styles.stepActive]}>
|
||||||
|
{step > s ? (
|
||||||
|
<Ionicons name="checkmark" size={16} color={Colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.stepNumber, step >= s && styles.stepNumberActive]}>{s}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{s < 3 && <View style={[styles.stepLine, step > s && styles.stepLineActive]} />}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Step 1: Emotion */}
|
||||||
|
{step === 1 && (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.stepTitle}>Comment vous sentez-vous ?</Text>
|
||||||
|
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.nextBtn, !emotionId && styles.btnDisabled]}
|
||||||
|
onPress={() => emotionId && setStep(2)}
|
||||||
|
disabled={!emotionId}
|
||||||
|
>
|
||||||
|
<Text style={styles.nextBtnText}>Suivant</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={20} color={Colors.black} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Intensity */}
|
||||||
|
{step === 2 && (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.stepTitle}>A quel point ressentez-vous cette emotion ?</Text>
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
<Text style={styles.intensityValue}>{intensite}</Text>
|
||||||
|
<Text style={styles.intensityLabel}>/10</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Text style={styles.sliderLabel}>Faible</Text>
|
||||||
|
<View style={styles.sliderWrapper}>
|
||||||
|
<View style={styles.nativeSlider}>
|
||||||
|
<View style={styles.sliderTrack}>
|
||||||
|
<View style={[styles.sliderFill, { width: `${(intensite - 1) * 11.11}%` }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.sliderButtons}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={v}
|
||||||
|
style={[styles.sliderDot, intensite === v && styles.sliderDotActive]}
|
||||||
|
onPress={() => setIntensite(v)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.sliderDotText, intensite === v && styles.sliderDotTextActive]}>{v}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.sliderLabel}>Forte</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.navRow}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(1)}>
|
||||||
|
<Ionicons name="arrow-back" size={20} color={Colors.gray[600]} />
|
||||||
|
<Text style={styles.backBtnText}>Retour</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.nextBtn} onPress={() => setStep(3)}>
|
||||||
|
<Text style={styles.nextBtnText}>Suivant</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={20} color={Colors.black} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Note */}
|
||||||
|
{step === 3 && (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.stepTitle}>Souhaitez-vous ajouter un commentaire ?</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
placeholder="Decrivez ce que vous ressentez, ce qui a declenche cette emotion..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={5}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
<View style={styles.navRow}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(2)}>
|
||||||
|
<Ionicons name="arrow-back" size={20} color={Colors.gray[600]} />
|
||||||
|
<Text style={styles.backBtnText}>Retour</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitBtn, createSaisie.isPending && styles.btnDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={createSaisie.isPending}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark" size={20} color={Colors.black} />
|
||||||
|
<Text style={styles.nextBtnText}>{createSaisie.isPending ? 'Envoi...' : 'Enregistrer'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
content: { padding: 20, paddingBottom: 40 },
|
||||||
|
stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: 32 },
|
||||||
|
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
stepCircle: {
|
||||||
|
width: 32, height: 32, borderRadius: 16, backgroundColor: Colors.gray[200],
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
stepActive: { backgroundColor: Colors.secondary },
|
||||||
|
stepNumber: { fontSize: 14, fontWeight: '700', color: Colors.gray[500] },
|
||||||
|
stepNumberActive: { color: Colors.white },
|
||||||
|
stepLine: { width: 40, height: 3, backgroundColor: Colors.gray[200], marginHorizontal: 4 },
|
||||||
|
stepLineActive: { backgroundColor: Colors.secondary },
|
||||||
|
stepTitle: { fontSize: 20, fontWeight: '700', color: Colors.black, marginBottom: 20 },
|
||||||
|
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: 24 },
|
||||||
|
intensityValue: { fontSize: 64, fontWeight: '800', color: Colors.black },
|
||||||
|
intensityLabel: { fontSize: 24, color: Colors.gray[400], marginLeft: 4 },
|
||||||
|
sliderContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 32 },
|
||||||
|
sliderLabel: { fontSize: 12, color: Colors.gray[400] },
|
||||||
|
sliderWrapper: { flex: 1 },
|
||||||
|
nativeSlider: {},
|
||||||
|
sliderTrack: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 8 },
|
||||||
|
sliderFill: { height: 6, backgroundColor: Colors.primary, borderRadius: 3 },
|
||||||
|
sliderButtons: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||||
|
sliderDot: { width: 28, height: 28, borderRadius: 14, backgroundColor: Colors.gray[100], justifyContent: 'center', alignItems: 'center' },
|
||||||
|
sliderDotActive: { backgroundColor: Colors.primary },
|
||||||
|
sliderDotText: { fontSize: 12, fontWeight: '600', color: Colors.gray[500] },
|
||||||
|
sliderDotTextActive: { color: Colors.black },
|
||||||
|
textArea: {
|
||||||
|
backgroundColor: Colors.gray[50], borderWidth: 1, borderColor: Colors.gray[200],
|
||||||
|
borderRadius: 16, padding: 16, fontSize: 15, color: Colors.black,
|
||||||
|
minHeight: 120, marginBottom: 24,
|
||||||
|
},
|
||||||
|
navRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16 },
|
||||||
|
backBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 20, borderRadius: 12, backgroundColor: Colors.gray[100] },
|
||||||
|
backBtnText: { fontSize: 15, fontWeight: '600', color: Colors.gray[600] },
|
||||||
|
nextBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 12, backgroundColor: Colors.primary },
|
||||||
|
submitBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 12, backgroundColor: Colors.secondary },
|
||||||
|
nextBtnText: { fontSize: 15, fontWeight: '700', color: Colors.black },
|
||||||
|
btnDisabled: { opacity: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRapport } from '@/hooks/useRapport';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
type Period = 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
|
||||||
|
const PERIODS: { value: Period; label: string }[] = [
|
||||||
|
{ value: 'week', label: 'Semaine' },
|
||||||
|
{ value: 'month', label: 'Mois' },
|
||||||
|
{ value: 'quarter', label: 'Trimestre' },
|
||||||
|
{ value: 'year', label: 'Annee' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReportsScreen() {
|
||||||
|
const [period, setPeriod] = useState<Period>('month');
|
||||||
|
const { data: rapport, isLoading } = useRapport(period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Period selector */}
|
||||||
|
<View style={styles.periodRow}>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p.value}
|
||||||
|
style={[styles.periodBtn, period === p.value && styles.periodBtnActive]}
|
||||||
|
onPress={() => setPeriod(p.value)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.periodText, period === p.value && styles.periodTextActive]}>
|
||||||
|
{p.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<Text style={styles.loadingText}>Chargement...</Text>
|
||||||
|
</View>
|
||||||
|
) : rapport ? (
|
||||||
|
<>
|
||||||
|
{/* Stats cards */}
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="bar-chart" size={20} color={Colors.primary} />
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
||||||
|
<Text style={styles.statLabel}>Saisies</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="trending-up" size={20} color={Colors.secondary} />
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
|
||||||
|
<Text style={styles.statLabel}>Intensite moy.</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="heart" size={20} color="#FF69B4" />
|
||||||
|
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
||||||
|
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Period info */}
|
||||||
|
<View style={styles.periodInfo}>
|
||||||
|
<Text style={styles.periodInfoText}>
|
||||||
|
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Repartition */}
|
||||||
|
{rapport.repartition.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Repartition des emotions</Text>
|
||||||
|
{rapport.repartition.map((item) => (
|
||||||
|
<View key={item.nom} style={styles.repartitionItem}>
|
||||||
|
<View style={styles.repartitionHeader}>
|
||||||
|
<View style={[styles.colorDot, { backgroundColor: item.couleur }]} />
|
||||||
|
<Text style={styles.repartitionName}>{item.nom}</Text>
|
||||||
|
<Text style={styles.repartitionPct}>{item.pourcentage}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.repartitionFooter}>
|
||||||
|
<Text style={styles.repartitionDetail}>{item.count} saisies</Text>
|
||||||
|
<Text style={styles.repartitionDetail}>Moy: {item.intensite_moyenne.toFixed(1)}/10</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="bar-chart-outline" size={48} color={Colors.gray[300]} />
|
||||||
|
<Text style={styles.emptyText}>Pas de donnees pour cette periode</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
content: { padding: 20, paddingBottom: 40 },
|
||||||
|
periodRow: { flexDirection: 'row', backgroundColor: Colors.gray[100], borderRadius: 12, padding: 4, marginBottom: 20 },
|
||||||
|
periodBtn: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: 10 },
|
||||||
|
periodBtnActive: { backgroundColor: Colors.white, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 },
|
||||||
|
periodText: { fontSize: 13, fontWeight: '600', color: Colors.gray[500] },
|
||||||
|
periodTextActive: { color: Colors.black },
|
||||||
|
loading: { padding: 40, alignItems: 'center' },
|
||||||
|
loadingText: { color: Colors.gray[400] },
|
||||||
|
statsRow: { flexDirection: 'row', gap: 10, marginBottom: 16 },
|
||||||
|
statCard: {
|
||||||
|
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 16, padding: 16,
|
||||||
|
alignItems: 'center', gap: 6, borderWidth: 1, borderColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
statValue: { fontSize: 22, fontWeight: '800', color: Colors.black },
|
||||||
|
statLabel: { fontSize: 11, color: Colors.gray[500] },
|
||||||
|
periodInfo: { marginBottom: 20 },
|
||||||
|
periodInfoText: { fontSize: 13, color: Colors.gray[400], textAlign: 'center' },
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 16 },
|
||||||
|
repartitionItem: { marginBottom: 16 },
|
||||||
|
repartitionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||||
|
colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 8 },
|
||||||
|
repartitionName: { flex: 1, fontSize: 14, fontWeight: '600', color: Colors.gray[700] },
|
||||||
|
repartitionPct: { fontSize: 14, fontWeight: '700', color: Colors.black },
|
||||||
|
progressBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 4 },
|
||||||
|
progressFill: { height: 6, borderRadius: 3 },
|
||||||
|
repartitionFooter: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||||
|
repartitionDetail: { fontSize: 12, color: Colors.gray[400] },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
|
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Alert.alert('Deconnexion', 'Voulez-vous vous deconnecter ?', [
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Se deconnecter',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await logout();
|
||||||
|
router.replace('/auth/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Avatar & Name */}
|
||||||
|
<View style={styles.profileHeader}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.prenom?.[0]}{user?.nom?.[0]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.name}>{user?.prenom} {user?.nom}</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
|
<View style={styles.roleBadge}>
|
||||||
|
<Text style={styles.roleText}>{user?.role?.nom || 'Membre'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Menu items */}
|
||||||
|
<View style={styles.menuSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Compte</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem icon="person-outline" label="Informations personnelles" />
|
||||||
|
<MenuItem icon="lock-closed-outline" label="Changer le mot de passe" />
|
||||||
|
<MenuItem icon="shield-checkmark-outline" label="Donnees personnelles (RGPD)" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Application</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem icon="information-circle-outline" label="A propos de CESIZen" />
|
||||||
|
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color="#DC143C" />
|
||||||
|
<Text style={styles.logoutText}>Se deconnecter</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.version}>CESIZen v1.0.0</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ icon, label }: { icon: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
|
<Ionicons name={icon as any} size={20} color={Colors.gray[600]} />
|
||||||
|
<Text style={styles.menuLabel}>{label}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
content: { padding: 20, paddingBottom: 40 },
|
||||||
|
profileHeader: { alignItems: 'center', marginBottom: 32, marginTop: 12 },
|
||||||
|
avatar: {
|
||||||
|
width: 80, height: 80, borderRadius: 40, backgroundColor: Colors.primary,
|
||||||
|
justifyContent: 'center', alignItems: 'center', marginBottom: 12,
|
||||||
|
},
|
||||||
|
avatarText: { fontSize: 28, fontWeight: '800', color: Colors.black },
|
||||||
|
name: { fontSize: 22, fontWeight: '700', color: Colors.black },
|
||||||
|
email: { fontSize: 14, color: Colors.gray[500], marginTop: 4 },
|
||||||
|
roleBadge: { backgroundColor: `${Colors.secondary}20`, paddingHorizontal: 12, paddingVertical: 4, borderRadius: 8, marginTop: 8 },
|
||||||
|
roleText: { fontSize: 12, fontWeight: '600', color: Colors.secondary, textTransform: 'capitalize' },
|
||||||
|
menuSection: { marginBottom: 24 },
|
||||||
|
sectionTitle: { fontSize: 13, fontWeight: '700', color: Colors.gray[400], textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 },
|
||||||
|
menuCard: { backgroundColor: Colors.gray[50], borderRadius: 16, overflow: 'hidden', borderWidth: 1, borderColor: Colors.gray[100] },
|
||||||
|
menuItem: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 15, paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: Colors.gray[100],
|
||||||
|
},
|
||||||
|
menuLabel: { flex: 1, fontSize: 15, color: Colors.gray[700] },
|
||||||
|
logoutBtn: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 16, borderRadius: 14, backgroundColor: '#DC143C10',
|
||||||
|
},
|
||||||
|
logoutText: { fontSize: 15, fontWeight: '600', color: '#DC143C' },
|
||||||
|
version: { textAlign: 'center', fontSize: 12, color: Colors.gray[400], marginTop: 20 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { View, ActivityIndicator } from 'react-native';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading, loadToken } = useAuthStore();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadToken();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
const inAuthGroup = segments[0] === 'auth';
|
||||||
|
|
||||||
|
if (!isAuthenticated && !inAuthGroup) {
|
||||||
|
router.replace('/auth/login');
|
||||||
|
} else if (isAuthenticated && inAuthGroup) {
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isAuthenticated, isLoading, segments]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
|
||||||
|
<ActivityIndicator size="large" color="#fce117" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthGuard>
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="auth" />
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
</Stack>
|
||||||
|
</AuthGuard>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
|
import { Link, useRouter } from 'expo-router';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!email || !password) {
|
||||||
|
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Erreur', error.response?.data?.message || 'Identifiants incorrects');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.logo}>CESIZen</Text>
|
||||||
|
<Text style={styles.subtitle}>Votre compagnon de sante mentale</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.title}>Connexion</Text>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Mot de passe</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Votre mot de passe"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>Pas encore de compte ? </Text>
|
||||||
|
<Link href="/auth/register" asChild>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<Text style={styles.link}>S'inscrire</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
||||||
|
header: { alignItems: 'center', marginBottom: 40 },
|
||||||
|
logo: { fontSize: 36, fontWeight: '800', color: Colors.black },
|
||||||
|
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 },
|
||||||
|
form: { backgroundColor: Colors.gray[50], borderRadius: 24, padding: 24 },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 },
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.gray[300],
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.black,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||||
|
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
||||||
|
footerText: { color: Colors.gray[500], fontSize: 14 },
|
||||||
|
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
|
import { Link, useRouter } from 'expo-router';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { register } = useAuthStore();
|
||||||
|
const [nom, setNom] = useState('');
|
||||||
|
const [prenom, setPrenom] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!nom || !prenom || !email || !password || !passwordConfirmation) {
|
||||||
|
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirmation) {
|
||||||
|
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register({ nom, prenom, email, password, password_confirmation: passwordConfirmation });
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Erreur', error.response?.data?.message || 'Erreur lors de l\'inscription');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.logo}>CESIZen</Text>
|
||||||
|
<Text style={styles.subtitle}>Creez votre compte</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.title}>Inscription</Text>
|
||||||
|
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
|
||||||
|
<Text style={styles.label}>Nom</Text>
|
||||||
|
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}>
|
||||||
|
<Text style={styles.label}>Prenom</Text>
|
||||||
|
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Mot de passe</Text>
|
||||||
|
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Confirmer le mot de passe</Text>
|
||||||
|
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}>
|
||||||
|
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>Deja un compte ? </Text>
|
||||||
|
<Link href="/auth/login" asChild>
|
||||||
|
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.white },
|
||||||
|
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
||||||
|
header: { alignItems: 'center', marginBottom: 32 },
|
||||||
|
logo: { fontSize: 36, fontWeight: '800', color: Colors.black },
|
||||||
|
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 },
|
||||||
|
form: { backgroundColor: Colors.gray[50], borderRadius: 24, padding: 24 },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 },
|
||||||
|
row: { flexDirection: 'row' },
|
||||||
|
inputGroup: { marginBottom: 16 },
|
||||||
|
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300],
|
||||||
|
borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black,
|
||||||
|
},
|
||||||
|
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||||
|
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
||||||
|
footerText: { color: Colors.gray[500], fontSize: 14 },
|
||||||
|
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
return <Redirect href={isAuthenticated ? '/(tabs)' : '/auth/login'} />;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
|
import { useEmotions } from '@/hooks/useEmotions';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import type { Emotion } from '@/types';
|
||||||
|
|
||||||
|
interface EmotionPickerProps {
|
||||||
|
value?: number;
|
||||||
|
onChange: (emotionId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
|
||||||
|
const { data: emotions, isLoading } = useEmotions();
|
||||||
|
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => setSelectedParent(null)}>
|
||||||
|
<Text style={styles.backText}>← Retour aux emotions</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.subTitle}>
|
||||||
|
Precisez votre emotion de{' '}
|
||||||
|
<Text style={{ color: selectedParent.couleur, fontWeight: '700' }}>{selectedParent.nom}</Text> :
|
||||||
|
</Text>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{selectedParent.enfants.map((child) => {
|
||||||
|
const isSelected = value === child.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={child.id}
|
||||||
|
style={[
|
||||||
|
styles.emotionCard,
|
||||||
|
isSelected && { borderColor: child.couleur, borderWidth: 2.5 },
|
||||||
|
]}
|
||||||
|
onPress={() => onChange(child.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.emotionEmoji}>{child.icone || '🔵'}</Text>
|
||||||
|
<Text style={styles.emotionName}>{child.nom}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{emotions?.map((emotion) => {
|
||||||
|
const hasChildren = emotion.enfants && emotion.enfants.length > 0;
|
||||||
|
const isSelected = value === emotion.id || emotion.enfants?.some((e) => e.id === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={emotion.id}
|
||||||
|
style={[
|
||||||
|
styles.emotionCard,
|
||||||
|
isSelected && { borderColor: emotion.couleur, borderWidth: 2.5 },
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
if (hasChildren) {
|
||||||
|
setSelectedParent(emotion);
|
||||||
|
} else {
|
||||||
|
onChange(emotion.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.emotionEmoji}>{emotion.icone || '🔵'}</Text>
|
||||||
|
<Text style={styles.emotionName}>{emotion.nom}</Text>
|
||||||
|
{hasChildren && <Text style={styles.arrow}>▼</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loading: { padding: 40, alignItems: 'center' },
|
||||||
|
backBtn: { marginBottom: 12 },
|
||||||
|
backText: { fontSize: 14, color: Colors.gray[500] },
|
||||||
|
subTitle: { fontSize: 14, color: Colors.gray[600], marginBottom: 12 },
|
||||||
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||||
|
emotionCard: {
|
||||||
|
width: '30%',
|
||||||
|
backgroundColor: Colors.gray[50],
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: Colors.gray[200],
|
||||||
|
},
|
||||||
|
emotionEmoji: { fontSize: 30 },
|
||||||
|
emotionName: { fontSize: 12, fontWeight: '600', color: Colors.gray[700], textAlign: 'center' },
|
||||||
|
arrow: { fontSize: 10, color: Colors.gray[400] },
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Emotion } from '@/types';
|
||||||
|
|
||||||
|
export function useEmotions() {
|
||||||
|
return useQuery<Emotion[]>({
|
||||||
|
queryKey: ['emotions'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/emotions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Feed } from '@/types';
|
||||||
|
|
||||||
|
export function useFeeds() {
|
||||||
|
return useQuery<Feed[]>({
|
||||||
|
queryKey: ['feeds'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/feeds');
|
||||||
|
return response.data.data ?? response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeed(slug: string) {
|
||||||
|
return useQuery<Feed>({
|
||||||
|
queryKey: ['feed', slug],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get(`/feeds/${slug}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { RapportData } from '@/types';
|
||||||
|
|
||||||
|
export function useRapport(period: 'week' | 'month' | 'quarter' | 'year') {
|
||||||
|
return useQuery<RapportData>({
|
||||||
|
queryKey: ['rapport', period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/tracker/rapports', { params: { period } });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!period,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { SaisieTracker } from '@/types';
|
||||||
|
|
||||||
|
interface SaisieFilters {
|
||||||
|
date_debut?: string;
|
||||||
|
date_fin?: string;
|
||||||
|
emotion_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaisies(filters?: SaisieFilters) {
|
||||||
|
return useQuery<SaisieTracker[]>({
|
||||||
|
queryKey: ['saisies', filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/tracker/saisies', { params: filters });
|
||||||
|
return response.data.data ?? response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateSaisie() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
emotion_id: number;
|
||||||
|
intensite: number;
|
||||||
|
note?: string;
|
||||||
|
date_saisie?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await api.post('/tracker/saisies', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['saisies'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSaisie() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: {
|
||||||
|
id: number;
|
||||||
|
emotion_id?: number;
|
||||||
|
intensite?: number;
|
||||||
|
note?: string;
|
||||||
|
date_saisie?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await api.put(`/tracker/saisies/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['saisies'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSaisie() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const response = await api.delete(`/tracker/saisies/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['saisies'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
const API_URL = Platform.select({
|
||||||
|
android: 'http://10.0.2.2:8000/api/v1',
|
||||||
|
ios: 'http://localhost:8000/api/v1',
|
||||||
|
default: 'http://localhost:8000/api/v1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const token = await SecureStore.getItemAsync('cesizen_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
await SecureStore.deleteItemAsync('cesizen_token');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import api from './api';
|
||||||
|
import type { Utilisateur } from '@/types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: Utilisateur | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (data: {
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
fetchUser: () => Promise<void>;
|
||||||
|
loadToken: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()((set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
|
||||||
|
loadToken: async () => {
|
||||||
|
const token = await SecureStore.getItemAsync('cesizen_token');
|
||||||
|
if (token) {
|
||||||
|
set({ token, isAuthenticated: true, isLoading: false });
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/me');
|
||||||
|
set({ user: response.data });
|
||||||
|
} catch {
|
||||||
|
await SecureStore.deleteItemAsync('cesizen_token');
|
||||||
|
set({ token: null, isAuthenticated: false, user: null, isLoading: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
const response = await api.post('/auth/login', { email, password });
|
||||||
|
const { utilisateur, token } = response.data;
|
||||||
|
await SecureStore.setItemAsync('cesizen_token', token);
|
||||||
|
set({ user: utilisateur, token, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (data) => {
|
||||||
|
const response = await api.post('/auth/register', data);
|
||||||
|
const { utilisateur, token } = response.data;
|
||||||
|
await SecureStore.setItemAsync('cesizen_token', token);
|
||||||
|
set({ user: utilisateur, token, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} catch {}
|
||||||
|
await SecureStore.deleteItemAsync('cesizen_token');
|
||||||
|
set({ user: null, token: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUser: async () => {
|
||||||
|
const response = await api.get('/auth/me');
|
||||||
|
set({ user: response.data });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const Colors = {
|
||||||
|
primary: '#fce117',
|
||||||
|
primaryDark: '#e5cc15',
|
||||||
|
secondary: '#06c656',
|
||||||
|
secondaryDark: '#05a348',
|
||||||
|
black: '#000000',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
gray: {
|
||||||
|
50: '#f9fafb',
|
||||||
|
100: '#f3f4f6',
|
||||||
|
200: '#e5e7eb',
|
||||||
|
300: '#d1d5db',
|
||||||
|
400: '#9ca3af',
|
||||||
|
500: '#6b7280',
|
||||||
|
600: '#4b5563',
|
||||||
|
700: '#374151',
|
||||||
|
800: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
},
|
||||||
|
emotions: {
|
||||||
|
joie: '#FFD700',
|
||||||
|
tristesse: '#4169E1',
|
||||||
|
colere: '#DC143C',
|
||||||
|
peur: '#9932CC',
|
||||||
|
degout: '#228B22',
|
||||||
|
surprise: '#FF8C00',
|
||||||
|
amour: '#FF69B4',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "cesizen-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~53.0.0",
|
||||||
|
"expo-router": "~5.0.0",
|
||||||
|
"expo-status-bar": "~2.2.0",
|
||||||
|
"expo-secure-store": "~14.2.0",
|
||||||
|
"expo-font": "~13.3.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-native": "0.79.2",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.10.0",
|
||||||
|
"react-native-reanimated": "~3.17.4",
|
||||||
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.3.0",
|
||||||
|
"@expo/vector-icons": "^14.0.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"react-native-svg": "~16.0.0",
|
||||||
|
"victory-native": "^41.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.0",
|
||||||
|
"@types/react": "~19.0.0",
|
||||||
|
"typescript": "~5.8.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Utilisateur {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
role_id: number;
|
||||||
|
role: Role;
|
||||||
|
est_actif: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Emotion {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
icone: string | null;
|
||||||
|
niveau: number;
|
||||||
|
parent_id: number | null;
|
||||||
|
est_actif: boolean;
|
||||||
|
enfants?: Emotion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaisieTracker {
|
||||||
|
id: number;
|
||||||
|
tracker_id: number;
|
||||||
|
emotion_id: number;
|
||||||
|
emotion: Emotion;
|
||||||
|
intensite: number;
|
||||||
|
note: string | null;
|
||||||
|
date_saisie: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feed {
|
||||||
|
id: number;
|
||||||
|
titre: string;
|
||||||
|
slug: string;
|
||||||
|
contenu: string;
|
||||||
|
image_url: string | null;
|
||||||
|
est_publie: boolean;
|
||||||
|
auteur_id: number;
|
||||||
|
auteur?: { id: number; nom: string; prenom: string };
|
||||||
|
ordre: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RapportStats {
|
||||||
|
total_saisies: number;
|
||||||
|
intensite_moyenne: number;
|
||||||
|
emotion_dominante: {
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
icone: string | null;
|
||||||
|
count: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RapportData {
|
||||||
|
periode: string;
|
||||||
|
date_debut: string;
|
||||||
|
date_fin: string;
|
||||||
|
stats: RapportStats;
|
||||||
|
repartition: {
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
icone: string | null;
|
||||||
|
count: number;
|
||||||
|
pourcentage: number;
|
||||||
|
intensite_moyenne: number;
|
||||||
|
}[];
|
||||||
|
evolution: {
|
||||||
|
labels: string[];
|
||||||
|
intensite_moyenne: number[];
|
||||||
|
emotions: {
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
data: number[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user