diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js
index cc7de36..56de9b7 100644
--- a/back/controllers/user.controller.js
+++ b/back/controllers/user.controller.js
@@ -125,6 +125,25 @@ exports.registerPushToken = async (req, res, next) => {
}
};
+/**
+ * MARQUER LE TUTORIEL COMME TERMINÉ
+ * Appelé par le front à la fin (ou au skip) du tutoriel interactif.
+ * Idempotent — voir user.service.js → completeOnboarding.
+ */
+exports.completeOnboarding = async (req, res, next) => {
+ try {
+ const user = await userService.completeOnboarding(req.user.id);
+
+ res.status(200).json({
+ success: true,
+ message: "Onboarding terminé.",
+ hasCompletedOnboarding: user.hasCompletedOnboarding,
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
/**
* SYNCHRONISER L'XP LOCALE
* Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers
diff --git a/back/middleware/rateLimit.middleware.js b/back/middleware/rateLimit.middleware.js
index c2ce31e..a0c1565 100644
--- a/back/middleware/rateLimit.middleware.js
+++ b/back/middleware/rateLimit.middleware.js
@@ -4,14 +4,19 @@ const { rateLimit, ipKeyGenerator } = require('express-rate-limit');
const config = require('../config/env');
// Les tests Jest enchaînent des centaines de requêtes : on désactive le
-// rate-limiting en environnement de test uniquement.
-const isTest = config.nodeEnv === 'test';
+// rate-limiting en environnement de test uniquement. Détection via
+// JEST_WORKER_ID (défini par Jest dans chaque worker) plutôt que NODE_ENV :
+// certains tests basculent temporairement config.nodeEnv vers 'production'
+// (pour vérifier les routes devOnly), ce qui réactivait par erreur le limiter
+// au milieu de la suite et provoquait des 429 non-déterministes. Évalué à
+// chaque requête (pas figé au chargement du module).
+const isTestEnv = () => process.env.JEST_WORKER_ID !== undefined || config.nodeEnv === 'test';
const standardOptions = {
windowMs: 15 * 60 * 1000,
standardHeaders: true, // RateLimit-* headers pour les clients
legacyHeaders: false,
- skip: () => isTest,
+ skip: () => isTestEnv(),
message: {
success: false,
status: 429,
diff --git a/back/models/User.js b/back/models/User.js
index 2197eb8..e892440 100644
--- a/back/models/User.js
+++ b/back/models/User.js
@@ -181,6 +181,14 @@ const UserSchema = new mongoose.Schema(
// ex. "FRAME_SHAPE_DRAGONFANG", "FRAME_COLOR_BLOODSANG", "THEME_BLOODSANG".
unlockedCosmetics: { type: [String], default: [] },
+ // ── Onboarding / Tutoriel interactif ──────────────────────────────────────
+ // Passe à true quand l'utilisateur termine (ou passe) le tutoriel interactif.
+ // Doublé côté client dans AsyncStorage (source immédiate du déclenchement),
+ // ce flag backend est la vérité inter-appareils : une réinstallation ou une
+ // connexion sur un nouvel appareil ne re-déclenche pas le tutoriel
+ // (voir TutorialContext.js → reconcileWithServer).
+ hasCompletedOnboarding: { type: Boolean, default: false },
+
// ── Présence & notifications push ─────────────────────────────────────────
// Token Expo Push (ExponentPushToken[...]) enregistré par le front après
// acceptation des permissions — voir push.service.js. null = aucun appareil
diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js
index fb62ae3..582df12 100644
--- a/back/routes/user.routes.js
+++ b/back/routes/user.routes.js
@@ -28,6 +28,9 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo
// Enregistrer (ou effacer) le token Expo Push de l'appareil courant
router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken);
+// Marquer le tutoriel/onboarding comme terminé — pas de body (idempotent)
+router.post("/me/complete-onboarding", auth, userController.completeOnboarding);
+
// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité
// pour le gating serveur (coffres niveau 11+, conditions de titres).
router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp);
diff --git a/back/services/user.service.js b/back/services/user.service.js
index a5e3d30..48f09cf 100644
--- a/back/services/user.service.js
+++ b/back/services/user.service.js
@@ -154,6 +154,24 @@ class UserService {
return updatedUser;
}
+ /**
+ * Marque le tutoriel/onboarding comme terminé pour cet utilisateur.
+ * Idempotent : rejouer ne change rien une fois le flag à true. Le client
+ * appelle ceci en best-effort à la fin (ou au skip) du tutoriel — la vérité
+ * immédiate reste AsyncStorage, ce flag backend sert la cohérence
+ * inter-appareils (voir TutorialContext.js).
+ * @param {string} userId
+ */
+ async completeOnboarding(userId) {
+ const updatedUser = await User.findByIdAndUpdate(
+ userId,
+ { $set: { hasCompletedOnboarding: true } },
+ { new: true },
+ ).select('hasCompletedOnboarding');
+ if (!updatedUser) throw new Error('Utilisateur non trouvé.');
+ return updatedUser;
+ }
+
/**
* Synchronise l'XP totale calculée localement (front, AsyncStorage-first)
* vers user.xp/level backend — Source de Vérité pour tout ce qui est gated
diff --git a/back/tests/user.test.js b/back/tests/user.test.js
index b073c3a..fa24284 100644
--- a/back/tests/user.test.js
+++ b/back/tests/user.test.js
@@ -76,6 +76,60 @@ describe('User API (Routes Protégées)', () => {
});
});
+ describe('POST /api/users/me/complete-onboarding — completeOnboarding (Tutoriel)', () => {
+ beforeEach(async () => {
+ await User.updateOne({ email: 'user@test.fr' }, { $set: { hasCompletedOnboarding: false } });
+ });
+
+ it('✅ hasCompletedOnboarding est false par défaut', async () => {
+ const res = await request(app)
+ .get('/api/users/me')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.body.user.hasCompletedOnboarding).toBe(false);
+ });
+
+ it('✅ Passe le flag à true et le persiste en base', async () => {
+ const res = await request(app)
+ .post('/api/users/me/complete-onboarding')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.hasCompletedOnboarding).toBe(true);
+
+ const user = await User.findOne({ email: 'user@test.fr' });
+ expect(user.hasCompletedOnboarding).toBe(true);
+ });
+
+ it('✅ Idempotent : rejouer laisse le flag à true', async () => {
+ await request(app)
+ .post('/api/users/me/complete-onboarding')
+ .set('Authorization', `Bearer ${token}`);
+ const second = await request(app)
+ .post('/api/users/me/complete-onboarding')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(second.statusCode).toBe(200);
+ expect(second.body.hasCompletedOnboarding).toBe(true);
+ });
+
+ it('✅ getMe reflète le flag une fois terminé', async () => {
+ await request(app)
+ .post('/api/users/me/complete-onboarding')
+ .set('Authorization', `Bearer ${token}`);
+
+ const me = await request(app)
+ .get('/api/users/me')
+ .set('Authorization', `Bearer ${token}`);
+ expect(me.body.user.hasCompletedOnboarding).toBe(true);
+ });
+
+ it('❌ 401 sans token', async () => {
+ const res = await request(app).post('/api/users/me/complete-onboarding');
+ expect(res.statusCode).toBe(401);
+ });
+ });
+
describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => {
const { xpForLevel } = require('../utils/levelHelpers');
diff --git a/front/src/components/tutorial/TutorialOverlay.js b/front/src/components/tutorial/TutorialOverlay.js
index 75de27b..91a7f0d 100644
--- a/front/src/components/tutorial/TutorialOverlay.js
+++ b/front/src/components/tutorial/TutorialOverlay.js
@@ -1,25 +1,29 @@
-import React, { useEffect, useRef, useCallback } from 'react';
+import React, { useEffect, useRef, useCallback, useState } from 'react';
import {
View, Text, StyleSheet, Modal, TouchableOpacity,
Animated, useWindowDimensions, Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTutorial } from '../../context/TutorialContext';
+import { CHAPTER_IDS } from '../../data/tutorialChapters';
+import { haptics } from '../../services/haptics.service';
import { Colors } from '../../constants/theme';
// Padding visuel autour du spotlight (léger, ne perturbe pas les coordonnées)
const SPOTLIGHT_PADDING = 8;
// Marge gauche/droite du tooltip
const TOOLTIP_MARGIN = 16;
-// Hauteur max estimée du tooltip — sert uniquement au calcul de placement
+// Hauteur estimée du tooltip AVANT sa première mesure (remplacée par la vraie
+// hauteur via onLayout dès le premier rendu)
const TOOLTIP_HEIGHT_EST = 220;
-// Seuil vertical (px) : si pageY de l'élément < THRESHOLD → tooltip en-dessous
-// sinon → tooltip au-dessus (algorithme strict demandé, indépendant de `position`)
-const ELEMENT_Y_THRESHOLD = 250;
// Écart gap entre le bord du spotlight et le tooltip
const TOOLTIP_GAP = 15;
// Marge de sécurité minimale par rapport aux bords de l'écran
const SCREEN_SAFE = 12;
+// Insets approximés : barre de statut/encoche en haut, indicateur/onglets en bas.
+// Gardent le tooltip lisible et cliquable hors des zones système.
+const SAFE_INSET_TOP = 44;
+const SAFE_INSET_BOTTOM = 28;
const GLOW_COLOR = 'rgba(254,116,57,0.55)';
// ─── ProgressDots ─────────────────────────────────────────────────────────────
@@ -51,11 +55,13 @@ const dots = StyleSheet.create({
// ─── ChapterBadge ─────────────────────────────────────────────────────────────
-function ChapterBadge({ chapter }) {
+function ChapterBadge({ chapter, index, total }) {
return (
- {chapter.subtitle} · {chapter.title}
+ Chapitre {index}/{total}
+
+ {chapter.title}
);
}
@@ -63,12 +69,14 @@ function ChapterBadge({ chapter }) {
const badge = StyleSheet.create({
wrap: {
flexDirection: 'row', alignItems: 'center', gap: 5,
- alignSelf: 'flex-start',
+ alignSelf: 'flex-start', maxWidth: '100%',
backgroundColor: 'rgba(254,116,57,0.12)',
borderWidth: 1, borderRadius: 8,
paddingHorizontal: 8, paddingVertical: 4, marginBottom: 10,
},
- text: { color: Colors.primary, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
+ counter: { color: Colors.primary, fontSize: 10, fontWeight: '900', letterSpacing: 0.5 },
+ sep: { width: 3, height: 3, borderRadius: 2, backgroundColor: Colors.primary, opacity: 0.6 },
+ text: { color: Colors.primary, fontSize: 10, fontWeight: '700', letterSpacing: 0.3, flexShrink: 1 },
});
// ─── TutorialOverlay ──────────────────────────────────────────────────────────
@@ -87,6 +95,10 @@ export default function TutorialOverlay({ navigation }) {
const slideY = useRef(new Animated.Value(24)).current;
const opacity = useRef(new Animated.Value(0)).current;
+ // Hauteur réelle du tooltip, mesurée au rendu (onLayout). Sert au calcul de
+ // placement : avec une estimation fixe, un texte long débordait sous l'écran
+ // et le bouton "Suivant" devenait inatteignable.
+ const [tooltipH, setTooltipH] = useState(TOOLTIP_HEIGHT_EST);
const animateIn = useCallback(() => {
slideY.setValue(24);
@@ -114,36 +126,49 @@ export default function TutorialOverlay({ navigation }) {
h: targetRect.height + SPOTLIGHT_PADDING * 2,
} : null;
- // ─── Positionnement du tooltip (algorithme strict basé sur pageY) ─────────
+ // ─── Positionnement du tooltip (espace réel + intention du design) ────────
//
- // Règle absolue :
- // • Pas de cible OU position === 'center' → centré verticalement
- // • pageY de l'élément < ELEMENT_Y_THRESHOLD (250 px)
- // → tooltip EN-DESSOUS : top = pageY + height + GAP
- // • pageY ≥ ELEMENT_Y_THRESHOLD
- // → tooltip AU-DESSUS : top = pageY - TOOLTIP_HEIGHT_EST - GAP
+ // Règles :
+ // • Pas de cible OU position === 'center' → centré verticalement.
+ // • Sinon on respecte le côté voulu par le design (position 'top' → tooltip
+ // AU-DESSUS de la cible, 'bottom' → EN-DESSOUS), MAIS on bascule sur
+ // l'autre côté s'il n'y a pas la place (cible trop haute/basse ou trop
+ // grande). On mesure la hauteur RÉELLE du tooltip (tooltipH) pour ne
+ // jamais le laisser déborder hors de l'écran : sans ça, un texte long
+ // poussait le bouton "Suivant" sous le bord bas, illisible et incliquable.
//
- // Les coordonnées utilisées sont celles de l'élément brut (targetRect),
- // pas les valeurs paddées du spotlight, pour un placement pixel-perfect.
+ // Les bornes verticales tiennent compte des marges hautes/basses de sécurité
+ // (SCREEN_SAFE + insets approximés) pour rester sous la barre d'onglets.
const tooltipStyle = (() => {
const base = { position: 'absolute', left: TOOLTIP_MARGIN, right: TOOLTIP_MARGIN };
+ const safeTop = SCREEN_SAFE + SAFE_INSET_TOP;
+ const safeBottom = H - SCREEN_SAFE - SAFE_INSET_BOTTOM;
+ const maxTop = Math.max(safeTop, safeBottom - tooltipH);
+
if (!hasSpot || activeStep.position === 'center') {
- return { ...base, top: Math.max(SCREEN_SAFE, H / 2 - TOOLTIP_HEIGHT_EST / 2) };
+ const centered = (safeTop + safeBottom) / 2 - tooltipH / 2;
+ return { ...base, top: Math.min(Math.max(safeTop, centered), maxTop) };
}
- const elemY = targetRect.y; // pageY réel de l'élément (coordonnée native)
- const elemH = targetRect.height; // hauteur réelle de l'élément
-
- if (elemY < ELEMENT_Y_THRESHOLD) {
- // Élément dans la partie HAUTE de l'écran → tooltip en-dessous
- const topBelow = elemY + elemH + TOOLTIP_GAP;
- return { ...base, top: Math.min(topBelow, H - TOOLTIP_HEIGHT_EST - SCREEN_SAFE) };
- } else {
- // Élément dans la partie BASSE/MILIEU → tooltip au-dessus
- const topAbove = elemY - TOOLTIP_HEIGHT_EST - TOOLTIP_GAP;
- return { ...base, top: Math.max(SCREEN_SAFE, topAbove) };
- }
+ const spotTop = targetRect.y - SPOTLIGHT_PADDING;
+ const spotBottom = targetRect.y + targetRect.height + SPOTLIGHT_PADDING;
+ const need = tooltipH + TOOLTIP_GAP;
+ const roomAbove = spotTop - safeTop;
+ const roomBelow = safeBottom - spotBottom;
+
+ // Côté souhaité par le design, conservé tant qu'il y a la place ; à défaut
+ // on prend le côté le plus spacieux (jamais par-dessus la cible).
+ const prefersAbove = activeStep.position === 'top';
+ const placeAbove = prefersAbove
+ ? (roomAbove >= need || roomAbove >= roomBelow)
+ : (roomBelow >= need ? false : roomAbove > roomBelow);
+
+ const rawTop = placeAbove
+ ? spotTop - TOOLTIP_GAP - tooltipH
+ : spotBottom + TOOLTIP_GAP;
+
+ return { ...base, top: Math.min(Math.max(safeTop, rawTop), maxTop) };
})();
const chapterColor = Colors.primary;
@@ -151,7 +176,23 @@ export default function TutorialOverlay({ navigation }) {
const isEndCard = !!activeStep.isLast;
const isAction = !!activeStep.actionRequired;
- const handleNext = () => nextStep(navigation);
+ // Position globale du chapitre courant (pour l'indicateur "Chapitre X/N").
+ const chapterIndex = CHAPTER_IDS.indexOf(activeChapter.id) + 1;
+ const totalChapters = CHAPTER_IDS.length;
+
+ const handleNext = () => {
+ // Retour haptique : lourd et marquant à la toute fin du tutoriel,
+ // léger sur une simple avance (étape ou chapitre suivant).
+ if (isEndCard) haptics.heavy();
+ else haptics.selection();
+ nextStep(navigation);
+ };
+
+ const handleSkip = () => {
+ // "Passer" ferme le tutoriel : léger retour haptique de confirmation.
+ haptics.selection();
+ dismiss();
+ };
const nextLabel = isEndCard ? 'Terminer le tutoriel ✓'
: isLastStep ? 'Chapitre suivant'
@@ -190,8 +231,14 @@ export default function TutorialOverlay({ navigation }) {
{
+ const h = e.nativeEvent.layout.height;
+ // Ne met à jour que sur variation nette (>1px) pour éviter les
+ // boucles de re-rendu dues aux arrondis sub-pixel.
+ if (h > 0 && Math.abs(h - tooltipH) > 1) setTooltipH(h);
+ }}
>
-
+
{activeStep.title}
{activeStep.body}
@@ -206,7 +253,7 @@ export default function TutorialOverlay({ navigation }) {
) : (
-
+
Passer
+
Passer le tutoriel
)}
diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js
index 70d7bd8..a6384b4 100644
--- a/front/src/context/TutorialContext.js
+++ b/front/src/context/TutorialContext.js
@@ -1,6 +1,7 @@
import React, { createContext, useState, useContext, useCallback, useEffect, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { TUTORIAL_CHAPTERS, CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters';
+import { completeOnboarding } from '../services/onboarding.service';
const DONE_KEY = 'athly:tutorial:completed:v1';
const PENDING_KEY = 'athly:tutorial:pendingChapter:v1';
@@ -110,6 +111,9 @@ export function TutorialProvider({ children }) {
AsyncStorage.setItem(DONE_KEY, 'true'),
AsyncStorage.removeItem(PENDING_KEY),
]);
+ // Persistance backend (best-effort, jamais bloquante) — cohérence
+ // inter-appareils du flag hasCompletedOnboarding.
+ completeOnboarding().catch(() => {});
return;
}
@@ -151,6 +155,18 @@ export function TutorialProvider({ children }) {
AsyncStorage.setItem(DONE_KEY, 'true'),
AsyncStorage.removeItem(PENDING_KEY),
]);
+ // "Passer le tutoriel" compte aussi comme terminé côté backend (best-effort).
+ completeOnboarding().catch(() => {});
+ }, []);
+
+ // Réconciliation avec le flag backend (hasCompletedOnboarding) une fois le
+ // profil chargé : si le serveur dit "déjà fait" alors que ce nouvel appareil
+ // n'a pas encore le flag local, on marque terminé pour ne pas re-déclencher
+ // le tutoriel. Ne fait jamais l'inverse (le serveur ne peut pas "ré-ouvrir").
+ const reconcileWithServer = useCallback(async (serverDone) => {
+ if (!serverDone) return;
+ setHasCompleted(true);
+ try { await AsyncStorage.setItem(DONE_KEY, 'true'); } catch (_) {}
}, []);
const clearJustCompleted = useCallback(() => setJustCompleted(false), []);
@@ -180,7 +196,7 @@ export function TutorialProvider({ children }) {
registerTarget, clearTargets,
registerScrollRef, registerRemeasure, scrollToStep,
startChapter, nextStep, prevStep, completeActionStep,
- dismiss, resetTutorial,
+ dismiss, resetTutorial, reconcileWithServer,
}}>
{children}
diff --git a/front/src/data/__tests__/tutorialChapters.test.js b/front/src/data/__tests__/tutorialChapters.test.js
new file mode 100644
index 0000000..ad8a283
--- /dev/null
+++ b/front/src/data/__tests__/tutorialChapters.test.js
@@ -0,0 +1,167 @@
+// @ts-check
+// Tests de non-régression des données du tutoriel interactif (tutorialChapters.js).
+// Ils tournent dans Node.js via Jest + babel-jest (transform ES → CJS) — aucune
+// dépendance React Native n'est requise.
+//
+// Objectif : garantir l'intégrité structurelle qui fait tourner la machine à états
+// du TutorialContext (navigation de chapitre en chapitre, spotlights, bouton
+// "Terminer" affiché au bon moment). Une régression ici casserait silencieusement
+// le tour guidé sans erreur JS.
+
+const {
+ TUTORIAL_CHAPTERS,
+ CHAPTER_MAP,
+ CHAPTER_IDS,
+} = require('../tutorialChapters');
+
+const VALID_POSITIONS = ['top', 'bottom', 'center'];
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Structure des chapitres
+// ─────────────────────────────────────────────────────────────────────────────
+describe('TUTORIAL_CHAPTERS — structure des chapitres', () => {
+ it('exporte un tableau non vide de chapitres', () => {
+ expect(Array.isArray(TUTORIAL_CHAPTERS)).toBe(true);
+ expect(TUTORIAL_CHAPTERS.length).toBeGreaterThan(0);
+ });
+
+ it('contient exactement les 8 chapitres attendus, dans l\'ordre', () => {
+ expect(CHAPTER_IDS).toEqual([
+ 'dashboard',
+ 'workout',
+ 'profile',
+ 'trophies',
+ 'inventory',
+ 'social',
+ 'stats',
+ 'settings',
+ ]);
+ });
+
+ const REQUIRED_CHAPTER_KEYS = ['id', 'title', 'subtitle', 'icon', 'tabName', 'steps'];
+
+ test.each(TUTORIAL_CHAPTERS)(
+ 'chapitre "$id" — possède toutes les propriétés obligatoires',
+ (chapter) => {
+ REQUIRED_CHAPTER_KEYS.forEach((key) => {
+ expect(chapter).toHaveProperty(key);
+ expect(chapter[key]).not.toBeUndefined();
+ expect(chapter[key]).not.toBeNull();
+ });
+ },
+ );
+
+ it('tous les IDs de chapitre sont uniques', () => {
+ const ids = TUTORIAL_CHAPTERS.map((c) => c.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('chaque chapitre a au moins une étape', () => {
+ TUTORIAL_CHAPTERS.forEach((chapter) => {
+ expect(Array.isArray(chapter.steps)).toBe(true);
+ expect(chapter.steps.length).toBeGreaterThan(0);
+ });
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Structure des étapes
+// ─────────────────────────────────────────────────────────────────────────────
+describe('TUTORIAL_CHAPTERS — structure des étapes', () => {
+ const allSteps = TUTORIAL_CHAPTERS.flatMap((c) =>
+ c.steps.map((s) => ({ chapterId: c.id, step: s })),
+ );
+
+ test.each(allSteps)(
+ '$chapterId/$step.key — champs texte et position valides',
+ ({ step }) => {
+ expect(typeof step.key).toBe('string');
+ expect(step.key.length).toBeGreaterThan(0);
+ expect(typeof step.title).toBe('string');
+ expect(step.title.length).toBeGreaterThan(0);
+ expect(typeof step.body).toBe('string');
+ expect(step.body.length).toBeGreaterThan(0);
+ expect(VALID_POSITIONS).toContain(step.position);
+ // targetKey est soit null (carte centrée), soit une chaîne non vide.
+ if (step.targetKey !== null) {
+ expect(typeof step.targetKey).toBe('string');
+ expect(step.targetKey.length).toBeGreaterThan(0);
+ }
+ },
+ );
+
+ it('les clés d\'étape sont uniques au sein de chaque chapitre', () => {
+ TUTORIAL_CHAPTERS.forEach((chapter) => {
+ const keys = chapter.steps.map((s) => s.key);
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+ });
+
+ it('toute étape avec un spotlight (targetKey) l\'associe à une position ancrée', () => {
+ // Un spotlight n'a de sens qu'ancré en haut/bas de la cible (pas 'center').
+ allSteps
+ .filter(({ step }) => step.targetKey !== null)
+ .forEach(({ chapterId, step }) => {
+ expect(['top', 'bottom']).toContain(step.position);
+ // message d'aide au debug si ça casse
+ if (!['top', 'bottom'].includes(step.position)) {
+ throw new Error(`${chapterId}/${step.key} a un targetKey mais position=${step.position}`);
+ }
+ });
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Cohérence du flag isLast (pilote l'affichage du bouton "Terminer")
+// ─────────────────────────────────────────────────────────────────────────────
+describe('TUTORIAL_CHAPTERS — flag isLast', () => {
+ it('exactement une étape porte isLast: true dans tout le tutoriel', () => {
+ const flagged = TUTORIAL_CHAPTERS.flatMap((c) =>
+ c.steps.filter((s) => s.isLast === true),
+ );
+ expect(flagged).toHaveLength(1);
+ });
+
+ it('isLast est porté par la toute dernière étape du dernier chapitre', () => {
+ const lastChapter = TUTORIAL_CHAPTERS[TUTORIAL_CHAPTERS.length - 1];
+ const lastStep = lastChapter.steps[lastChapter.steps.length - 1];
+ expect(lastStep.isLast).toBe(true);
+ });
+
+ it('aucune autre étape que la dernière ne porte isLast', () => {
+ TUTORIAL_CHAPTERS.forEach((chapter, ci) => {
+ chapter.steps.forEach((step, si) => {
+ const isVeryLast =
+ ci === TUTORIAL_CHAPTERS.length - 1 && si === chapter.steps.length - 1;
+ if (!isVeryLast) {
+ expect(step.isLast).not.toBe(true);
+ }
+ });
+ });
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Maps dérivées (utilisées par TutorialContext pour la navigation d'état)
+// ─────────────────────────────────────────────────────────────────────────────
+describe('CHAPTER_MAP & CHAPTER_IDS — cohérence dérivée', () => {
+ it('CHAPTER_IDS correspond à l\'ordre des chapitres', () => {
+ expect(CHAPTER_IDS).toEqual(TUTORIAL_CHAPTERS.map((c) => c.id));
+ });
+
+ it('CHAPTER_MAP indexe chaque chapitre par son id', () => {
+ TUTORIAL_CHAPTERS.forEach((chapter) => {
+ expect(CHAPTER_MAP[chapter.id]).toBe(chapter);
+ });
+ expect(Object.keys(CHAPTER_MAP)).toHaveLength(TUTORIAL_CHAPTERS.length);
+ });
+
+ it('chaque chapitre avec stackScreen déclare aussi son tabName', () => {
+ // La transition inter-chapitres navigue via navigation.navigate(tabName,
+ // { screen: stackScreen }) — stackScreen sans tabName casserait la navigation.
+ TUTORIAL_CHAPTERS.filter((c) => c.stackScreen).forEach((c) => {
+ expect(typeof c.tabName).toBe('string');
+ expect(c.tabName.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js
index b9551e8..66255f8 100644
--- a/front/src/data/tutorialChapters.js
+++ b/front/src/data/tutorialChapters.js
@@ -1,4 +1,4 @@
-// Définition des 6 chapitres du tutoriel interactif Athly.
+// Définition des 8 chapitres du tutoriel interactif Athly.
//
// Propriétés d'une step :
// targetKey – clé d'une cible enregistrée via useTutorialTarget (null = pas de spotlight)
@@ -23,7 +23,7 @@ export const TUTORIAL_CHAPTERS = [
{
key: 'welcome',
title: 'Bienvenue sur Athly !',
- body: "Athly transforme chaque séance en progression réelle. Ce guide de 6 chapitres te montrera tout ce que tu peux accomplir.",
+ body: "Athly transforme chaque séance en progression réelle. Ce guide de 8 chapitres te montrera tout ce que tu peux accomplir.",
targetKey: null, position: 'center', scrollY: 0,
},
{
@@ -139,6 +139,12 @@ export const TUTORIAL_CHAPTERS = [
body: "Ces 3 slots affichent tes trophées préférés. Appuie sur un slot vide pour choisir dans la Salle des Trophées.",
targetKey: 'profile_vitrine', position: 'top', scrollY: 260,
},
+ {
+ key: 'profile_titles',
+ title: 'Tes Titres RPG',
+ body: "Via le bouton 'Titres', équipe un titre affiché sous ton pseudo. 17 titres se débloquent en accomplissant des défis (records, séances Multi, entraide…).",
+ targetKey: null, position: 'center', scrollY: 0,
+ },
],
},
@@ -154,7 +160,7 @@ export const TUTORIAL_CHAPTERS = [
{
key: 'trophies_intro',
title: 'La Salle des Trophées',
- body: "40 trophées à débloquer, répartis en 8 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social et Spécial.",
+ body: "60 trophées à débloquer, répartis en 9 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social, Spécial et Collection.",
targetKey: null, position: 'center', scrollY: 0,
},
{
@@ -166,7 +172,7 @@ export const TUTORIAL_CHAPTERS = [
{
key: 'trophies_ultimate',
title: 'Le Trophée Ultime',
- body: "Le 'Souverain Absolu' se débloque automatiquement quand les 40 autres trophées sont obtenus. Un accomplissement absolu.",
+ body: "Le 'Souverain Absolu' se débloque automatiquement quand les 60 autres trophées sont obtenus. Un accomplissement absolu.",
targetKey: 'trophies_ultimate', position: 'bottom', scrollY: 0,
},
{
@@ -178,11 +184,95 @@ export const TUTORIAL_CHAPTERS = [
],
},
- // ─── CHAPITRE 5 : Les Statistiques ─────────────────────────────────────────
+ // ─── CHAPITRE 5 : Inventaire, Coffres & Raretés ────────────────────────────
+ {
+ id: 'inventory',
+ title: 'Coffres & Raretés',
+ subtitle: 'Chapitre 5',
+ icon: 'cube-outline',
+ tabName: 'ProfileTab',
+ stackScreen: 'Inventory',
+ steps: [
+ {
+ key: 'inventory_intro',
+ title: "L'Inventaire",
+ body: "Chaque effort te récompense : boissons d'XP, gels de streak, boosts et coupons s'accumulent ici, prêts à être utilisés au bon moment.",
+ targetKey: null, position: 'center', scrollY: 0,
+ },
+ {
+ key: 'inventory_chest',
+ title: 'Coffres à l\'Effort',
+ body: "Tu gagnes un coffre toutes les 2 h de séance cumulées (débloqué au rang Initié, niveau 11). Ouvre-le pour tirer un objet au hasard.",
+ targetKey: 'inventory_chest', position: 'bottom', scrollY: 0,
+ },
+ {
+ key: 'inventory_rarities',
+ title: '5 Raretés',
+ body: "Du Commun (gris) au Légendaire (orange), en passant par Rare (bleu) et Épique (violet) : plus c'est rare, plus l'objet est puissant.",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ {
+ key: 'inventory_unique',
+ title: 'Le Rouge Sang Unique',
+ body: "La rareté ultime : les cosmétiques Uniques Rouge Sang (cadre, couleur, thème) se méritent par des exploits rares et se réclament ici. Le prestige absolu.",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ ],
+ },
+
+ // ─── CHAPITRE 6 : Social, Groupes & Multi ──────────────────────────────────
+ {
+ id: 'social',
+ title: 'Amis, Groupes & Multi',
+ subtitle: 'Chapitre 6',
+ icon: 'people-outline',
+ tabName: 'SocialTab',
+ stackScreen: 'SocialHub',
+ steps: [
+ {
+ key: 'social_intro',
+ title: 'Progresse en Meute',
+ body: "Athly est bien plus fort à plusieurs. Ajoute tes amis, compare-toi, et entraîne-toi en équipe.",
+ targetKey: null, position: 'center', scrollY: 0,
+ },
+ {
+ key: 'social_segments',
+ title: 'Amis, Classement, Groupe',
+ body: "Ces trois onglets réunissent ton réseau : tes amis, les classements (XP et records par exercice), et ton Groupe de Streak.",
+ targetKey: 'social_segments', position: 'bottom', scrollY: 0,
+ },
+ {
+ key: 'social_add',
+ title: 'Ajouter un Ami',
+ body: "Chaque joueur a un tag unique façon Discord (ex: Player#1234). Saisis-le pour envoyer une demande, après un aperçu de son profil.",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ {
+ key: 'social_group',
+ title: 'Groupes de Streak & Météo',
+ body: "Formez un groupe (5 max) : si TOUS validez votre journée, la streak collective grimpe. La Météo des séances montre en direct qui est Prêt, Actif ou a Validé.",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ {
+ key: 'social_shame',
+ title: 'Hall of Shame & Secouer',
+ body: "Si la streak de groupe casse, le dernier Briseur s'affiche dans le Hall of Shame. Le bouton 'Secouer' envoie une notif troll aux retardataires pour les motiver.",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ {
+ key: 'social_multi',
+ title: 'Le Mode Multi',
+ body: "Invite des amis dans un Lobby Multi : vous démarrez la séance ensemble, chacun sur son écran. Terminer à plusieurs débloque un bonus d'XP de groupe (+15% à 2, jusqu'à +50% à 5).",
+ targetKey: null, position: 'center', scrollY: null,
+ },
+ ],
+ },
+
+ // ─── CHAPITRE 7 : Les Statistiques ─────────────────────────────────────────
{
id: 'stats',
title: 'Les Statistiques',
- subtitle: 'Chapitre 5',
+ subtitle: 'Chapitre 7',
icon: 'stats-chart-outline',
tabName: 'Stats',
// useMockData = true → StatsScreen injecte les fausses données durant ce chapitre
@@ -200,6 +290,12 @@ export const TUTORIAL_CHAPTERS = [
body: "Séances, sets validés et volume total pour la période sélectionnée. Change la période avec les boutons Semaine / Mois / Tout.",
targetKey: 'stats_kpis', position: 'bottom', scrollY: 60,
},
+ {
+ key: 'stats_weight',
+ title: 'Suivi de Poids',
+ body: "Le graphique double courbe suit ton poids réel vers ton objectif. Appuie sur 'Ajouter une pesée' pour l'actualiser et garder un suivi au top.",
+ targetKey: 'stats_weight_chart', position: 'bottom', scrollY: 60,
+ },
{
key: 'stats_volume',
title: 'Graphique de Volume',
@@ -222,11 +318,11 @@ export const TUTORIAL_CHAPTERS = [
],
},
- // ─── CHAPITRE 6 : Réglages ──────────────────────────────────────────────────
+ // ─── CHAPITRE 8 : Réglages ──────────────────────────────────────────────────
{
id: 'settings',
title: 'Réglages & Compte',
- subtitle: 'Chapitre 6',
+ subtitle: 'Chapitre 8',
icon: 'settings-outline',
tabName: 'ProfileTab',
stackScreen: 'Settings',
diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js
index c8aba2c..a5117ff 100644
--- a/front/src/screens/Home/HomeScreen.js
+++ b/front/src/screens/Home/HomeScreen.js
@@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Colors, MUSCLE_GROUP_COLORS } from '../../constants/theme';
import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
+import { useUser } from '../../context/UserContext';
import {
computeStreak,
recommendNextMuscleGroup,
@@ -49,9 +50,16 @@ export default function HomeScreen({ navigation }) {
const {
hasCompleted, bootstrapped, pendingChapterId, startChapter,
activeChapterId, activeStep, stepIndex,
- registerScrollRef, registerRemeasure,
+ registerScrollRef, registerRemeasure, reconcileWithServer,
} = useTutorial();
+ // Réconciliation du flag "tutoriel terminé" avec le backend (cohérence
+ // inter-appareils) : si le serveur dit "déjà fait", on ne re-déclenche pas.
+ const { user } = useUser();
+ useEffect(() => {
+ if (user) reconcileWithServer(!!user.hasCompletedOnboarding);
+ }, [user, reconcileWithServer]);
+
// Injection de données fantômes pendant le Chapitre 1 pour que le spotlight
// puisse pointer les éléments actifs (level chip, hero, stats, quêtes, rituel).
const isTutorialDashboard = activeChapterId === 'dashboard';
@@ -98,6 +106,9 @@ export default function HomeScreen({ navigation }) {
useFocusEffect(
useCallback(() => {
if (!bootstrapped) return;
+ // Garde inter-appareils : ne pas auto-lancer si le backend confirme que
+ // le tutoriel a déjà été fait (même si le flag local n'est pas encore là).
+ if (user && user.hasCompletedOnboarding) return;
if (!hasCompleted && activeChapterId === null) {
const timer = setTimeout(() => startChapter('dashboard'), 600);
return () => clearTimeout(timer);
@@ -106,7 +117,7 @@ export default function HomeScreen({ navigation }) {
const timer = setTimeout(() => startChapter('dashboard'), 400);
return () => clearTimeout(timer);
}
- }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter]),
+ }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter, user]),
);
// ─── Anim d'entrée pour éviter le flash content au 1er chargement ────
diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js
index 6001f53..43cc559 100644
--- a/front/src/screens/Profile/InventoryScreen.js
+++ b/front/src/screens/Profile/InventoryScreen.js
@@ -15,6 +15,8 @@ import { useAvatarFrame } from '../../hooks/useAvatarFrame';
import { useDevSettings } from '../../hooks/useDevSettings';
import ChestOpeningModal from '../../components/inventory/ChestOpeningModal';
import { haptics } from '../../services/haptics.service';
+import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
+import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
const MIN_LEVEL_FOR_CHEST = 11;
const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common'];
@@ -42,8 +44,21 @@ export default function InventoryScreen({ navigation }) {
const [busy, setBusy] = useState(false);
const [chestModal, setChestModal] = useState({ visible: false, drawnItem: null });
+ // ─── Tutorial (chapitre Inventaire) ──────────────────────────────────────────
+ const { pendingChapterId, activeChapterId, startChapter } = useTutorial();
+ const { ref: chestRef, onLayout: onChestLayout } = useTutorialTarget('inventory_chest');
+
useFocusEffect(useCallback(() => { refetch(); }, [refetch]));
+ useFocusEffect(
+ useCallback(() => {
+ if (pendingChapterId === 'inventory') {
+ const t = setTimeout(() => startChapter('inventory'), 400);
+ return () => clearTimeout(t);
+ }
+ }, [pendingChapterId, startChapter]),
+ );
+
const inventory = user?.inventory ?? [];
const chestEntry = inventory.find((i) => i.itemType === 'CHEST_KEY');
const chestCount = chestEntry?.quantity ?? 0;
@@ -150,12 +165,16 @@ export default function InventoryScreen({ navigation }) {
{/* ── Coffres ── */}
-
+ {/* marginTop porté par le wrapper (et non la carte) pour que le
+ spotlight du tutoriel épouse pile la carte, sans décalage vertical. */}
+
+
+
{/* ── Objets ── */}
MES OBJETS
@@ -187,6 +206,10 @@ export default function InventoryScreen({ navigation }) {
drawnItem={chestModal.drawnItem}
onClose={closeChestModal}
/>
+
+ {activeChapterId === 'inventory' && (
+
+ )}
);
}
@@ -338,6 +361,7 @@ const styles = StyleSheet.create({
},
// ── Coffre ──
+ chestSpot: { marginTop: 8 },
chestCard: {
flexDirection: 'row',
alignItems: 'center',
@@ -346,7 +370,6 @@ const styles = StyleSheet.create({
borderColor: 'rgba(254,116,57,0.30)',
borderRadius: 18,
padding: 18,
- marginTop: 8,
},
chestCardLocked: {
backgroundColor: 'rgba(255,255,255,0.04)',
diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js
index 4ea224f..4511499 100644
--- a/front/src/screens/Profile/SettingsScreen.js
+++ b/front/src/screens/Profile/SettingsScreen.js
@@ -1173,7 +1173,7 @@ export default function SettingsScreen({ navigation }) {
Rejouer l'intégralité du tutoriel
- 5 chapitres · du Dashboard aux Réglages
+ 8 chapitres · du Dashboard aux Réglages
@@ -1192,7 +1192,7 @@ export default function SettingsScreen({ navigation }) {
{chapter.title}
- {chapter.subtitle} · {chapter.steps.length} étapes
+ Chapitre {idx + 1} · {chapter.steps.length} étapes
diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js
index 2a05fef..23289c3 100644
--- a/front/src/screens/Social/SocialScreen.js
+++ b/front/src/screens/Social/SocialScreen.js
@@ -21,6 +21,8 @@ import {
} from '../../services/social.service';
import { MAJOR_EXERCISES } from '../../data/majorExercises';
import ExercisePickerModal from '../../components/social/ExercisePickerModal';
+import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
+import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
// Podium / classements : positions 1-3 affichées en médaille colorée plutôt
// qu'en emoji 🥇🥈🥉.
@@ -48,6 +50,10 @@ export default function SocialScreen({ navigation }) {
const { addBonusXp } = useWorkoutLogs();
const [segment, setSegment] = useState('friends');
+ // ─── Tutorial (chapitre Social) ──────────────────────────────────────────────
+ const { pendingChapterId, activeChapterId, startChapter } = useTutorial();
+ const { ref: segmentsRef, onLayout: onSegmentsLayout } = useTutorialTarget('social_segments');
+
// ── Données ──
const [friends, setFriends] = useState([]);
const [pending, setPending] = useState([]);
@@ -130,6 +136,15 @@ export default function SocialScreen({ navigation }) {
useFocusEffect(useCallback(() => { loadAll(); }, [loadAll]));
+ useFocusEffect(
+ useCallback(() => {
+ if (pendingChapterId === 'social') {
+ const t = setTimeout(() => startChapter('social'), 400);
+ return () => clearTimeout(t);
+ }
+ }, [pendingChapterId, startChapter]),
+ );
+
const onRefresh = () => { setRefreshing(true); loadAll(); };
// Recherche déclenchée depuis AddFriendModal, avec le tag déjà construit à
@@ -173,7 +188,12 @@ export default function SocialScreen({ navigation }) {
Social
{/* ── Segments ── */}
-
+
{SEGMENTS.map((s) => {
const active = segment === s.key;
const badge = s.key === 'friends' && pending.length > 0 ? pending.length
@@ -335,6 +355,10 @@ export default function SocialScreen({ navigation }) {
}}
onClose={() => setPreviewResult(null)}
/>
+
+ {activeChapterId === 'social' && (
+
+ )}
);
}
diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js
index 7ef02db..6c49d54 100644
--- a/front/src/screens/Stats/StatsScreen.js
+++ b/front/src/screens/Stats/StatsScreen.js
@@ -67,7 +67,12 @@ export default function StatsScreen({ navigation }) {
} = useTutorial();
const scrollRef = useRef(null);
+ // Offset de scroll courant, suivi en direct pour un défilement piloté par la
+ // position RÉELLE des cibles (robuste aux changements de mise en page comme
+ // l'ajout du graphique de poids, qui décalait les anciens scrollY fixes).
+ const scrollOffsetRef = useRef(0);
const { ref: kpisRef, onLayout: onKpisLayout, remeasure: rKpis } = useTutorialTarget('stats_kpis');
+ const { ref: weightRef, onLayout: onWeightLayout, remeasure: rWeight } = useTutorialTarget('stats_weight_chart');
const { ref: volumeRef, onLayout: onVolumeLayout, remeasure: rVolume } = useTutorialTarget('stats_volume_chart');
const { ref: muscleRef, onLayout: onMuscleLayout, remeasure: rMuscle } = useTutorialTarget('stats_muscle_chart');
const { ref: tabHistoryRef, onLayout: onTabHistoryLayout } = useTutorialTarget('stats_tab_history');
@@ -76,9 +81,24 @@ export default function StatsScreen({ navigation }) {
useEffect(() => {
registerScrollRef('stats', scrollRef);
registerRemeasure('stats', () => {
- setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 50);
+ setTimeout(() => { rKpis(); rWeight(); rVolume(); rMuscle(); }, 50);
});
- }, [registerScrollRef, registerRemeasure, rKpis, rVolume, rMuscle]);
+ }, [registerScrollRef, registerRemeasure, rKpis, rWeight, rVolume, rMuscle]);
+
+ // Fait défiler pour amener une cible mesurée à une position confortable :
+ // haut de cible vers ~160 px (tooltip en-dessous) ou ~300 px (tooltip
+ // au-dessus). On mesure en absolu (pageY) et on combine avec l'offset courant,
+ // donc aucun nombre magique ne dépend de la hauteur des sections au-dessus.
+ const scrollTargetIntoView = useCallback((targetRef, prefersAbove, remeasureAll) => {
+ if (!targetRef?.current || !scrollRef.current) return;
+ const desiredTop = prefersAbove ? 300 : 160;
+ targetRef.current.measure((_x, _y, _w, _h, _pageX, pageY) => {
+ if (pageY == null) return;
+ const newY = Math.max(0, scrollOffsetRef.current + (pageY - desiredTop));
+ scrollRef.current.scrollTo({ y: newY, animated: true });
+ if (remeasureAll) setTimeout(remeasureAll, 350);
+ });
+ }, []);
// Démarrage du chapitre quand l'écran gagne le focus.
// On force d'abord l'onglet Performance pour éviter que l'utilisateur,
@@ -93,13 +113,33 @@ export default function StatsScreen({ navigation }) {
}, [pendingChapterId, startChapter]),
);
- // Auto-scroll + autoActions quand l'étape change
+ // Auto-scroll + autoActions quand l'étape change.
+ // Défilement piloté par la cible (robuste) pour les sections défilables ;
+ // pour la cible d'onglet Historique (barre de nav haute) on remonte en tête.
useEffect(() => {
if (activeChapterId !== 'stats' || !activeStep) return;
- const y = activeStep.scrollY;
- if (y != null && scrollRef.current) {
- scrollRef.current.scrollTo({ y, animated: true });
- setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 350);
+
+ const remeasureAll = () => { rKpis(); rWeight(); rVolume(); rMuscle(); };
+ const REF_BY_KEY = {
+ stats_kpis: kpisRef,
+ stats_weight_chart: weightRef,
+ stats_volume_chart: volumeRef,
+ stats_muscle_chart: muscleRef,
+ };
+ const targetRef = activeStep.targetKey ? REF_BY_KEY[activeStep.targetKey] : null;
+
+ if (targetRef) {
+ // Laisse le rendu se stabiliser puis amène la cible à bonne hauteur.
+ const t = setTimeout(
+ () => scrollTargetIntoView(targetRef, activeStep.position === 'top', remeasureAll),
+ 80,
+ );
+ return () => clearTimeout(t);
+ }
+ // Cibles hors flux défilable (onglet) ou cartes centrées : scroll fixe.
+ if (activeStep.scrollY != null && scrollRef.current) {
+ scrollRef.current.scrollTo({ y: activeStep.scrollY, animated: true });
+ setTimeout(remeasureAll, 350);
}
if (activeStep.autoAction === 'switchToHistory') {
const t = setTimeout(() => setTab('history'), 300);
@@ -159,6 +199,8 @@ export default function StatsScreen({ navigation }) {
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
+ scrollEventThrottle={16}
+ onScroll={(e) => { scrollOffsetRef.current = e.nativeEvent.contentOffset.y; }}
>
Statistiques
@@ -194,17 +236,19 @@ export default function StatsScreen({ navigation }) {
-
-
- setWeightEntryVisible(true)}
- activeOpacity={0.85}
- >
-
- Ajouter une pesée
-
-
+
+
+
+ setWeightEntryVisible(true)}
+ activeOpacity={0.85}
+ >
+
+ Ajouter une pesée
+
+
+
diff --git a/front/src/services/onboarding.service.js b/front/src/services/onboarding.service.js
new file mode 100644
index 0000000..4059778
--- /dev/null
+++ b/front/src/services/onboarding.service.js
@@ -0,0 +1,10 @@
+import API from '../api/api';
+
+// ─── Onboarding / Tutoriel interactif ──────────────────────────────────────────
+// Marque le tutoriel terminé côté backend — best-effort : la vérité immédiate
+// reste AsyncStorage (voir TutorialContext.js), ce flag serveur ne sert que la
+// cohérence inter-appareils. Ne jamais bloquer l'UI sur cet appel.
+export async function completeOnboarding() {
+ const res = await API.post('/users/me/complete-onboarding');
+ return res.data;
+}