en
+ // position:fixed attaché à document.body — INDÉPENDANT du conteneur #root
+ // (recentré à 430 px sur desktop via web/index.html). measure() renvoie déjà
+ // des coordonnées relatives au viewport complet, le même repère que celui
+ // utilisé par ce Modal plein-viewport : aucune correction d'offset n'est à
+ // appliquer. Un ancien code soustrayait un offset "modalLeft" en supposant
+ // le Modal lui-même recentré à 430 px — ce qui décalait le spotlight de
+ // plusieurs centaines de pixels sur une fenêtre desktop large (le cadre se
+ // retrouvait loin de la carte réellement visible, recentrée dans #root).
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);
@@ -108,42 +125,55 @@ export default function TutorialOverlay({ navigation }) {
const hasSpot = !!targetRect;
const sp = hasSpot ? {
- x: (targetRect.x - modalLeft) - SPOTLIGHT_PADDING,
+ x: targetRect.x - SPOTLIGHT_PADDING,
y: targetRect.y - SPOTLIGHT_PADDING,
w: targetRect.width + SPOTLIGHT_PADDING * 2,
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 +181,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 +236,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,11 +258,11 @@ export default function TutorialOverlay({ navigation }) {
) : (
-
+
Passer
@@ -225,7 +277,7 @@ export default function TutorialOverlay({ navigation }) {
{/* Bouton Passer toujours accessible même en actionRequired */}
{isAction && (
-
+
Passer le tutoriel
)}
diff --git a/front/src/components/typography/SectionTitle.js b/front/src/components/typography/SectionTitle.js
deleted file mode 100644
index 782dcec..0000000
--- a/front/src/components/typography/SectionTitle.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// ============================================================================================
-// Composant SectionTitle
-// Affiche un titre de section avec un style uniforme
-// ============================================================================================
-
-import React from "react";
-import { Text, StyleSheet } from "react-native";
-import { Colors } from "../../constants/theme";
-
-export default function SectionTitle({ title }) {
- return {title};
-}
-
-const styles = StyleSheet.create({
- section: {
- color: Colors.textPrimary,
- fontSize: 18,
- marginVertical: 16,
- fontWeight: "bold",
- },
-});
\ No newline at end of file
diff --git a/front/src/components/ui/AppToast.js b/front/src/components/ui/AppToast.js
index ea8d914..fbeaeb7 100644
--- a/front/src/components/ui/AppToast.js
+++ b/front/src/components/ui/AppToast.js
@@ -4,13 +4,14 @@ import {
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
// ─── Config par type ─────────────────────────────────────────────────────────
const TYPE_CONFIG = {
- success: { icon: 'checkmark-circle', color: '#22C55E' },
- error: { icon: 'close-circle', color: '#FF4D4D' },
- warning: { icon: 'warning', color: '#F59E0B' },
+ success: { icon: 'checkmark-circle', color: Colors.valid },
+ error: { icon: 'close-circle', color: Colors.error },
+ warning: { icon: 'warning', color: Colors.warningAmber },
info: { icon: 'information-circle', color: '#4A9EFF' },
};
@@ -132,7 +133,7 @@ const styles = StyleSheet.create({
},
message: {
flex: 1,
- color: '#FFFFFF',
+ color: Colors.textPrimary,
fontSize: 14,
fontWeight: '600',
lineHeight: 20,
diff --git a/front/src/components/web/DesktopInstallPage.js b/front/src/components/web/DesktopInstallPage.js
index 1485fde..eb7e80a 100644
--- a/front/src/components/web/DesktopInstallPage.js
+++ b/front/src/components/web/DesktopInstallPage.js
@@ -1,5 +1,7 @@
import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
const LOGO = require('../../../assets/logo-orange.png');
@@ -18,7 +20,7 @@ function MethodCard({ icon, title, platform, steps, accent }) {
return (
- {icon}
+
{title}
{platform}
@@ -56,10 +58,10 @@ export default function DesktopInstallPage() {
- {icon}
+
@@ -203,7 +203,7 @@ const styles = StyleSheet.create({
width: 44,
height: 44,
borderRadius: 10,
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
diff --git a/front/src/components/workouts/ExerciseHeader.js b/front/src/components/workouts/ExerciseHeader.js
index 4bd0c99..295dad7 100644
--- a/front/src/components/workouts/ExerciseHeader.js
+++ b/front/src/components/workouts/ExerciseHeader.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import {
View,
Text,
@@ -6,10 +6,10 @@ import {
TouchableOpacity,
StyleSheet,
Linking,
- Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../../constants/theme';
+import InfoModal from '../common/InfoModal';
const LOGO = require('../../../assets/minimalist-logo-orange.png');
@@ -29,15 +29,17 @@ function ChronoPill({ timer }) {
}
export default function ExerciseHeader({ timer = '0:00', videoUrl }) {
+ const [infoModal, setInfoModal] = useState(null); // { title, body }
+
const onPlayPress = async () => {
if (!videoUrl) {
- Alert.alert('Vidéo indisponible', "Aucun lien vidéo n'est associé à cet exercice.");
+ setInfoModal({ title: 'Vidéo indisponible', body: "Aucun lien vidéo n'est associé à cet exercice." });
return;
}
try {
await Linking.openURL(videoUrl);
} catch (e) {
- Alert.alert('Erreur', "Impossible d'ouvrir la vidéo.");
+ setInfoModal({ title: 'Erreur', body: "Impossible d'ouvrir la vidéo." });
}
};
@@ -63,6 +65,14 @@ export default function ExerciseHeader({ timer = '0:00', videoUrl }) {
+
+ setInfoModal(null)}
+ />
);
}
@@ -73,7 +83,7 @@ const styles = StyleSheet.create({
paddingTop: 12,
paddingBottom: 22,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
+ borderBottomColor: Colors.borderSubtle,
},
topRow: {
flexDirection: 'row',
diff --git a/front/src/components/workouts/InlineExerciseBlock.js b/front/src/components/workouts/InlineExerciseBlock.js
new file mode 100644
index 0000000..a4e1f93
--- /dev/null
+++ b/front/src/components/workouts/InlineExerciseBlock.js
@@ -0,0 +1,243 @@
+import React, { useCallback, useState } from 'react';
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import * as Haptics from 'expo-haptics';
+import { Colors } from '../../constants/theme';
+import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext';
+import {
+ primaryMuscleLabel,
+ secondaryMusclesLabels,
+ pickExerciseIcon,
+} from '../../constants/exerciseFilters';
+import SetTable from './SetTable';
+import ActionSheetModal from '../common/ActionSheetModal';
+
+// Bloc exercice pour la vue "Voir tous les exercices" (all-in-one).
+// Affiche le titre, les muscles, le SetTable compact et le bouton [+ Série].
+// La vidéo est volontairement masquée pour maximiser l'espace de saisie.
+//
+// Props :
+// - exercise : objet exercice (avec .sets)
+// - exerciseIndex : index dans state.exercises
+// - onRemoveExercise : (exerciseIndex, exercise) => void
+// - onReplaceExercise : (exerciseIndex) => void
+
+function InlineExerciseBlock({ exercise, exerciseIndex, onRemoveExercise, onReplaceExercise }) {
+ const { actions } = useWorkoutInProgress();
+
+ const title = (exercise && (exercise.name || exercise.title)) || 'Exercice';
+ const icon = pickExerciseIcon(exercise);
+ const primary = primaryMuscleLabel(exercise);
+ const secondary = secondaryMusclesLabels(exercise);
+ const sets = (exercise && Array.isArray(exercise.sets)) ? exercise.sets : [];
+ const isDone = !!exercise?.done;
+
+ const completedCount = sets.filter((s) => !!s.completed).length;
+ const allDone = sets.length > 0 && completedCount === sets.length;
+
+ const handleToggle = useCallback((i) => {
+ actions.toggleSet(exerciseIndex, i);
+ }, [actions, exerciseIndex]);
+
+ const handleChange = useCallback((i, patch) => {
+ const cur = sets[i] || {};
+ const weight = patch.weight !== undefined ? patch.weight : cur.weight;
+ const reps = patch.reps !== undefined ? patch.reps : cur.reps;
+ actions.updateSet(exerciseIndex, i, {
+ weight: typeof weight === 'number' ? weight : Number(weight) || 0,
+ reps: typeof reps === 'number' ? reps : Number(reps) || 0,
+ });
+ }, [actions, exerciseIndex, sets]);
+
+ const handleAdd = useCallback(() => {
+ actions.addSet(exerciseIndex);
+ }, [actions, exerciseIndex]);
+
+ const handleRemove = useCallback((i) => {
+ actions.removeSet(exerciseIndex, i);
+ }, [actions, exerciseIndex]);
+
+ const [actionSheetVisible, setActionSheetVisible] = useState(false);
+
+ const showActions = useCallback(() => {
+ try { Haptics.selectionAsync(); } catch (_) {}
+ setActionSheetVisible(true);
+ }, []);
+
+ const actionOptions = [
+ ...(onReplaceExercise ? [{ label: 'Remplacer', onPress: () => onReplaceExercise(exerciseIndex) }] : []),
+ ...(onRemoveExercise ? [{
+ label: 'Supprimer',
+ destructive: true,
+ onPress: () => onRemoveExercise(exerciseIndex, exercise),
+ }] : []),
+ ];
+
+ return (
+
+
+ {/* ── En-tête exercice ──────────────────────────────────────────── */}
+
+
+
+
+
+
+ {title}
+ {(primary || secondary.length > 0) ? (
+
+ {primary ? {primary} : null}
+ {primary && secondary.length > 0 ? ' • ' : null}
+ {secondary.length > 0 ? (
+ {secondary.join(', ')}
+ ) : null}
+
+ ) : null}
+
+
+ {/* Compteur séries complétées */}
+
+ {allDone
+ ?
+ : null}
+
+ {completedCount}/{sets.length}
+
+
+
+
+
+
+ {/* ── Tableau des séries (compact = sans marge) ─────────────────── */}
+
+
+ {/* ── Ajouter une série ─────────────────────────────────────────── */}
+
+
+ Ajouter une série
+
+
+ setActionSheetVisible(false)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ block: {
+ marginHorizontal: 16,
+ marginBottom: 14,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: Colors.borderSubtle,
+ backgroundColor: Colors.cardDeep,
+ overflow: 'hidden',
+ },
+ blockDone: {
+ opacity: 0.6,
+ borderColor: 'rgba(34,197,94,0.30)',
+ },
+
+ // ── En-tête ─────────────────────────────────────────────────────────────
+ blockHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 11,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.borderSubtle,
+ gap: 10,
+ },
+ iconBox: {
+ width: 38,
+ height: 38,
+ borderRadius: 10,
+ backgroundColor: Colors.cardInner,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ icon: {
+ fontSize: 20,
+ },
+ headerText: {
+ flex: 1,
+ },
+ title: {
+ color: Colors.textPrimary,
+ fontSize: 15,
+ fontWeight: '700',
+ letterSpacing: 0.1,
+ },
+ muscles: {
+ marginTop: 2,
+ fontSize: 12,
+ },
+ musclePrimary: {
+ color: Colors.primary,
+ fontWeight: '600',
+ },
+ muscleSecondary: {
+ color: Colors.textSecondary,
+ },
+ progressBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ borderRadius: 10,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+ progressBadgeDone: {
+ backgroundColor: 'rgba(34,197,94,0.10)',
+ },
+ progressText: {
+ color: Colors.textMuted,
+ fontSize: 12,
+ fontWeight: '700',
+ },
+ progressTextDone: {
+ color: Colors.valid,
+ },
+ menuIcon: {
+ marginLeft: 2,
+ },
+
+ // ── Footer "+ Série" ─────────────────────────────────────────────────────
+ addBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 11,
+ gap: 5,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: Colors.borderSubtle,
+ },
+ addBtnText: {
+ color: Colors.primary,
+ fontSize: 13,
+ fontWeight: '700',
+ },
+});
+
+export default React.memo(InlineExerciseBlock);
diff --git a/front/src/components/workouts/LobbyInviteCheck.js b/front/src/components/workouts/LobbyInviteCheck.js
new file mode 100644
index 0000000..0c05b33
--- /dev/null
+++ b/front/src/components/workouts/LobbyInviteCheck.js
@@ -0,0 +1,69 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import * as Notifications from 'expo-notifications';
+import { Platform } from 'react-native';
+import { useAuth } from '../../context/AuthContext';
+import { joinLobby } from '../../services';
+import { navigate } from '../../navigation/navigationRef';
+import LobbyInviteModal from './LobbyInviteModal';
+
+// ─── LobbyInviteCheck ─────────────────────────────────────────────────────────
+// À monter une fois dans l'arbre authentifié (AppNavigator), au même niveau
+// que WeightReminderCheck / ActivityFeedModal. Écoute les notifications push
+// `lobby_invite` (voir back/services/push.service.js) :
+// - reçue au premier plan → popup custom "X t'invite..." (Rejoindre/Ignorer)
+// - tapée depuis l'arrière-plan/fermée → rejoint directement, sans popup
+// (l'utilisateur a déjà exprimé son intention en tapant la notification)
+
+export default function LobbyInviteCheck() {
+ const { userToken } = useAuth();
+ const [invite, setInvite] = useState(null); // { lobbyId, fromPseudo }
+ const joiningRef = useRef(false);
+
+ const handleJoinLobby = useCallback(async (lobbyId) => {
+ if (!lobbyId || joiningRef.current) return;
+ joiningRef.current = true;
+ try { await joinLobby(lobbyId); } catch (_) {}
+ joiningRef.current = false;
+ navigate('Séances', { screen: 'WorkoutList', params: { pendingLobbyId: lobbyId } });
+ }, []);
+
+ useEffect(() => {
+ if (Platform.OS === 'web' || !userToken) return undefined;
+
+ const receivedSub = Notifications.addNotificationReceivedListener((notification) => {
+ const data = notification?.request?.content?.data;
+ if (data?.type === 'lobby_invite' && data?.lobbyId) {
+ setInvite({ lobbyId: data.lobbyId, fromPseudo: data.fromPseudo });
+ }
+ });
+
+ const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
+ const data = response?.notification?.request?.content?.data;
+ if (data?.type === 'lobby_invite' && data?.lobbyId) {
+ handleJoinLobby(data.lobbyId);
+ }
+ });
+
+ return () => {
+ receivedSub.remove();
+ responseSub.remove();
+ };
+ }, [userToken, handleJoinLobby]);
+
+ const onJoin = useCallback(() => {
+ const lobbyId = invite?.lobbyId;
+ setInvite(null);
+ handleJoinLobby(lobbyId);
+ }, [invite, handleJoinLobby]);
+
+ const onDismiss = useCallback(() => setInvite(null), []);
+
+ return (
+
+ );
+}
diff --git a/front/src/components/workouts/LobbyInviteModal.js b/front/src/components/workouts/LobbyInviteModal.js
new file mode 100644
index 0000000..07ddd41
--- /dev/null
+++ b/front/src/components/workouts/LobbyInviteModal.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+
+// ─── LobbyInviteModal ─────────────────────────────────────────────────────────
+// Popup affichée quand une notification push `lobby_invite` est reçue pendant
+// que l'app est au premier plan (voir LobbyInviteCheck) — remplace tout
+// Alert.alert natif, cohérent avec le reste de l'app.
+//
+// Props :
+// visible bool
+// fromPseudo string
+// onJoin () => void — rejoint le lobby
+// onDismiss () => void — ignore l'invitation
+
+export default function LobbyInviteModal({ visible, fromPseudo, onJoin, onDismiss }) {
+ return (
+
+
+
+
+
+
+
+ Invitation Multi
+
+ {fromPseudo || 'Un ami'} t'invite à réaliser une séance ensemble !
+
+
+
+
+ Ignorer
+
+
+ Rejoindre
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.82)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}38`,
+ padding: 28,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.65,
+ shadowRadius: 32,
+ elevation: 20,
+ },
+ iconWrap: {
+ width: 56, height: 56, borderRadius: 16,
+ backgroundColor: `${Colors.primary}1A`,
+ borderWidth: 1, borderColor: `${Colors.primary}45`,
+ justifyContent: 'center', alignItems: 'center', marginBottom: 16,
+ },
+ title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 10, textAlign: 'center' },
+ body: { color: Colors.textSecondary, fontSize: 14, lineHeight: 20, textAlign: 'center', marginBottom: 24 },
+ btnRow: { flexDirection: 'row', width: '100%', gap: 10 },
+ dismissBtn: {
+ flex: 1, height: 50, borderRadius: 13,
+ justifyContent: 'center', alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)',
+ },
+ dismissBtnTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' },
+ joinBtn: {
+ flex: 1, height: 50, borderRadius: 13,
+ justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.primary,
+ shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4, shadowRadius: 12, elevation: 6,
+ },
+ joinBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700' },
+});
diff --git a/front/src/components/workouts/LobbyMembersBar.js b/front/src/components/workouts/LobbyMembersBar.js
new file mode 100644
index 0000000..b7325bb
--- /dev/null
+++ b/front/src/components/workouts/LobbyMembersBar.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+
+const STATUS_META = {
+ waiting: { icon: 'time-outline', color: Colors.textMuted },
+ ready: { icon: 'flash', color: '#FBBF24' },
+ finished: { icon: 'checkmark-circle', color: Colors.success },
+};
+
+// ─── LobbyMembersBar ──────────────────────────────────────────────────────────
+// Bulles des participants connectés au lobby (Section VII), affichées en haut
+// de l'écran de séance pendant l'effort — chacun gère ses séries de son côté
+// (résilience réseau), cette barre est juste un indicateur de présence/statut,
+// rafraîchie par polling (voir WorkoutScreen.js).
+//
+// Props : members Array<{ user: {_id, pseudo}, status }>
+
+export default function LobbyMembersBar({ members = [] }) {
+ if (members.length === 0) return null;
+
+ return (
+
+
+ {members.map((m) => {
+ const meta = STATUS_META[m.status] ?? STATUS_META.waiting;
+ return (
+
+ {(m.user.pseudo ?? '?').charAt(0).toUpperCase()}
+
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row', alignItems: 'center',
+ paddingHorizontal: 16, paddingVertical: 8,
+ },
+ bubble: {
+ width: 26, height: 26, borderRadius: 8,
+ backgroundColor: 'rgba(254,116,57,0.16)',
+ justifyContent: 'center', alignItems: 'center',
+ marginRight: 6, borderWidth: 1.5, borderColor: Colors.background,
+ },
+ bubbleTxt: { color: Colors.primary, fontSize: 11, fontWeight: '800' },
+ dot: {
+ position: 'absolute', bottom: -2, right: -2,
+ width: 8, height: 8, borderRadius: 4,
+ borderWidth: 1, borderColor: Colors.background,
+ },
+});
diff --git a/front/src/components/workouts/LobbyWaitingOverlay.js b/front/src/components/workouts/LobbyWaitingOverlay.js
new file mode 100644
index 0000000..54be323
--- /dev/null
+++ b/front/src/components/workouts/LobbyWaitingOverlay.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import { View, Text, StyleSheet, Modal, ActivityIndicator } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+
+const STATUS_LABEL = {
+ waiting: 'En cours…',
+ ready: 'En cours…',
+ finished: 'Terminé !',
+};
+
+// ─── LobbyWaitingOverlay ──────────────────────────────────────────────────────
+// Écran de clôture synchrone (Section VII) : dès que l'utilisateur clique
+// "Finir la séance", son statut passe 'finished' et l'app bloque son écran
+// ici jusqu'à ce que TOUS les membres du lobby aient fini — moment où le
+// parent (WorkoutScreen.js) détecte lobby.status === 'completed' et bascule
+// vers MultiLootModal.
+//
+// Modale plein écran non-fermable (pas de bouton retour) — c'est voulu : la
+// séance est déjà validée, il n'y a rien à annuler.
+
+export default function LobbyWaitingOverlay({ visible, members = [] }) {
+ return (
+
+
+
+
+ En attente de tes partenaires...
+
+ Ta séance est enregistrée. Le butin d'équipe sera distribué dès que
+ tout le monde aura terminé.
+
+
+
+ {members.map((m) => (
+
+ {m.user.pseudo}
+
+ {m.status === 'finished'
+ ?
+ : }
+
+ {STATUS_LABEL[m.status] ?? 'En cours…'}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.92)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}38`,
+ padding: 28,
+ alignItems: 'center',
+ },
+ title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 10, textAlign: 'center' },
+ body: { color: Colors.textSecondary, fontSize: 13, lineHeight: 19, textAlign: 'center', marginBottom: 22 },
+ list: { width: '100%', gap: 4 },
+ row: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
+ paddingVertical: 10, paddingHorizontal: 4,
+ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)',
+ },
+ pseudo: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700', flex: 1, marginRight: 10 },
+ statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ statusTxt: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' },
+});
diff --git a/front/src/components/workouts/MultiLobbyModal.js b/front/src/components/workouts/MultiLobbyModal.js
new file mode 100644
index 0000000..625640d
--- /dev/null
+++ b/front/src/components/workouts/MultiLobbyModal.js
@@ -0,0 +1,443 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { View, Text, StyleSheet, Modal, TouchableOpacity, ActivityIndicator, FlatList } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+import { createLobby, getLobby, joinLobby, inviteToLobby, readyLobby, unreadyLobby } from '../../services';
+import { getFriendsList } from '../../services';
+import { useUser } from '../../context/UserContext';
+
+const POLL_MS = 3000;
+const INVITE_COOLDOWN_MS = 30000;
+
+const STATUS_META = {
+ waiting: { icon: 'time-outline', color: Colors.textMuted },
+ ready: { icon: 'checkmark-circle', color: Colors.success },
+ finished: { icon: 'flag', color: Colors.primary },
+};
+
+// Bonus XP Multi — miroir de computeMultiBonusPercent (back/workoutLobby.controller.js) :
+// généreux à dessein, un groupe de 5 est rare (2 joueurs → 15%, 3 → 25%,
+// 4 → 35%, 5 → 50%).
+const MULTI_BONUS_BY_COUNT = { 1: 0, 2: 0.15, 3: 0.25, 4: 0.35, 5: 0.50 };
+function computeBonusPercent(memberCount) {
+ if (memberCount >= 5) return MULTI_BONUS_BY_COUNT[5];
+ return MULTI_BONUS_BY_COUNT[memberCount] ?? 0;
+}
+
+const BONUS_TABLE = [1, 2, 3, 4, 5].map((n) => ({ count: n, percent: computeBonusPercent(n) }));
+
+// ─── BonusTableModal ──────────────────────────────────────────────────────────
+// Petit tableau expliquant le bonus XP de groupe par effectif — ouvert en
+// appuyant sur le chip de bonus dans MultiLobbyModal.
+function BonusTableModal({ visible, memberCount, onClose }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Bonus XP de groupe
+
+ Plus vous êtes nombreux, plus le bonus grimpe. Assembler un groupe de 5 est difficile - ça se mérite !
+
+
+
+ {BONUS_TABLE.map((row) => (
+
+
+ {row.count} joueur{row.count > 1 ? 's' : ''}
+
+
+ +{Math.round(row.percent * 100)}%
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// ─── MultiLobbyModal ──────────────────────────────────────────────────────────
+// Étape de lancement d'une séance en Multi (Section VII) : crée le lobby (ou
+// rejoint un lobby existant depuis une invitation reçue), permet d'inviter des
+// amis (vraie notification push, avec cooldown anti-spam de 30s), affiche qui
+// a rejoint et son statut ainsi que le bonus XP de groupe, et se ferme
+// automatiquement (onReady) dès que le lobby passe 'active' — c'est-à-dire dès
+// que 100% des membres sont prêts.
+//
+// Props :
+// visible bool
+// existingLobbyId string | null — rejoint ce lobby au lieu d'en créer un neuf
+// onClose () => void — annule (le lobby reste en base, abandonné)
+// onReady (lobbyId: string) => void — lobby actif, lance la séance
+
+export default function MultiLobbyModal({ visible, existingLobbyId, onClose, onReady }) {
+ const { user } = useUser();
+ const myId = user?._id;
+
+ const [lobby, setLobby] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [friends, setFriends] = useState([]);
+ const [invitePanelOpen, setInvitePanelOpen] = useState(false);
+ const [readying, setReadying] = useState(false);
+ const [bonusTableVisible, setBonusTableVisible] = useState(false);
+ const [invitedAt, setInvitedAt] = useState({}); // { friendId: timestampMs }
+ const [now, setNow] = useState(Date.now());
+ const pollRef = useRef(null);
+ const cooldownTickRef = useRef(null);
+ const firedReadyRef = useRef(false);
+
+ useEffect(() => {
+ if (!visible) return;
+ firedReadyRef.current = false;
+ setLoading(true);
+ setInvitePanelOpen(false);
+ setInvitedAt({});
+
+ (async () => {
+ try {
+ const [lobbyRes, friendsRes] = await Promise.all([
+ existingLobbyId ? joinLobby(existingLobbyId) : createLobby(),
+ getFriendsList(),
+ ]);
+ setLobby(lobbyRes.lobby);
+ setFriends(friendsRes.friends ?? []);
+ } catch (_) {
+ // Best-effort — fermeture silencieuse si la création/jointure échoue.
+ } finally {
+ setLoading(false);
+ }
+ })();
+
+ return () => { if (pollRef.current) clearInterval(pollRef.current); };
+ }, [visible, existingLobbyId]);
+
+ // Poll l'état du lobby — pas de websocket, cohérent avec le reste de l'app
+ // (Météo des séances, Groupe de streak...).
+ useEffect(() => {
+ if (!visible || !lobby?._id) return;
+ pollRef.current = setInterval(async () => {
+ try {
+ const res = await getLobby(lobby._id);
+ setLobby(res.lobby);
+ if (res.lobby.status === 'active' && !firedReadyRef.current) {
+ firedReadyRef.current = true;
+ clearInterval(pollRef.current);
+ onReady(res.lobby._id);
+ }
+ } catch (_) {
+ // best-effort
+ }
+ }, POLL_MS);
+ return () => clearInterval(pollRef.current);
+ }, [visible, lobby?._id, onReady]);
+
+ // Tick pour rafraîchir les cercles de cooldown d'invitation (1x/s).
+ useEffect(() => {
+ if (!visible) return undefined;
+ cooldownTickRef.current = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(cooldownTickRef.current);
+ }, [visible]);
+
+ const handleInvite = useCallback(async (friendId) => {
+ if (!lobby) return;
+ const startedAt = invitedAt[friendId];
+ if (startedAt && Date.now() - startedAt < INVITE_COOLDOWN_MS) return;
+ setInvitedAt((prev) => ({ ...prev, [friendId]: Date.now() }));
+ try { await inviteToLobby(lobby._id, friendId); } catch (_) {}
+ }, [lobby, invitedAt]);
+
+ const myMember = (lobby?.members ?? []).find((m) => m.user._id === myId);
+ const isReady = myMember?.status === 'ready';
+ const canReady = (lobby?.memberCount ?? 0) >= 2;
+
+ const handleToggleReady = useCallback(async () => {
+ if (!lobby) return;
+ setReadying(true);
+ try {
+ const res = isReady ? await unreadyLobby(lobby._id) : await readyLobby(lobby._id);
+ setLobby(res.lobby);
+ if (res.lobby.status === 'active' && !firedReadyRef.current) {
+ firedReadyRef.current = true;
+ onReady(res.lobby._id);
+ }
+ } catch (_) {
+ // best-effort
+ } finally {
+ setReadying(false);
+ }
+ }, [lobby, isReady, onReady]);
+
+ const memberIds = new Set((lobby?.members ?? []).map((m) => m.user._id));
+ const invitableFriends = friends.filter((f) => !memberIds.has(f.user._id));
+ const bonusPercent = computeBonusPercent(lobby?.memberCount ?? 1);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Séance en Multi
+
+ Invite jusqu'à 4 amis. La séance démarre pour tout le monde dès que
+ chacun se déclare prêt.
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {(lobby?.members ?? []).map((m) => {
+ const meta = STATUS_META[m.status] ?? STATUS_META.waiting;
+ return (
+
+
+ {(m.user.pseudo ?? '?').charAt(0).toUpperCase()}
+
+
+
+
+ {m.user.pseudo}
+
+ );
+ })}
+
+
+ setBonusTableVisible(true)} activeOpacity={0.8}>
+
+ Bonus de groupe : +{Math.round(bonusPercent * 100)}% XP
+
+
+
+ setInvitePanelOpen((v) => !v)}
+ activeOpacity={0.8}
+ >
+
+ Inviter un ami
+
+
+
+ {invitePanelOpen && (
+
+ {invitableFriends.length === 0 ? (
+ Tous tes amis sont déjà dans le lobby (ou tu n'as pas d'amis disponibles).
+ ) : (
+ f.user._id}
+ style={{ maxHeight: 160 }}
+ renderItem={({ item }) => {
+ const startedAt = invitedAt[item.user._id];
+ const elapsedMs = startedAt ? now - startedAt : Infinity;
+ const onCooldown = elapsedMs < INVITE_COOLDOWN_MS;
+ const remainingSec = onCooldown ? Math.ceil((INVITE_COOLDOWN_MS - elapsedMs) / 1000) : 0;
+ return (
+ handleInvite(item.user._id)}
+ activeOpacity={0.75}
+ disabled={onCooldown}
+ >
+ {item.user.pseudo}
+ {onCooldown ? (
+
+ {remainingSec}
+
+ ) : (
+
+ )}
+
+ );
+ }}
+ />
+ )}
+
+ )}
+
+
+ {readying
+ ?
+ : (
+ <>
+
+
+ {isReady ? 'Je ne suis plus prêt' : 'Je suis prêt'}
+
+ >
+ )}
+
+
+ {canReady
+ ? 'En attente que tout le monde soit prêt pour démarrer ensemble…'
+ : 'Il faut au moins 2 joueurs dans le lobby pour se déclarer prêt.'}
+
+ >
+ )}
+
+
+
+ setBonusTableVisible(false)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.82)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}38`,
+ padding: 24,
+ paddingTop: 36,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.65,
+ shadowRadius: 32,
+ elevation: 20,
+ },
+ closeIcon: { position: 'absolute', top: 14, right: 14, zIndex: 1 },
+ iconWrap: {
+ width: 56, height: 56, borderRadius: 16,
+ backgroundColor: `${Colors.primary}1A`,
+ borderWidth: 1, borderColor: `${Colors.primary}45`,
+ justifyContent: 'center', alignItems: 'center', marginBottom: 14,
+ },
+ title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 8, textAlign: 'center' },
+ body: { color: Colors.textSecondary, fontSize: 13, lineHeight: 19, textAlign: 'center', marginBottom: 18 },
+ membersRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 14, marginBottom: 18, width: '100%' },
+ memberBubbleWrap: { alignItems: 'center', width: 56 },
+ memberBubble: {
+ width: 44, height: 44, borderRadius: 14,
+ backgroundColor: 'rgba(254,116,57,0.14)',
+ justifyContent: 'center', alignItems: 'center',
+ },
+ memberBubbleTxt: { color: Colors.primary, fontSize: 16, fontWeight: '800' },
+ statusDot: {
+ position: 'absolute', bottom: -4, right: -4,
+ width: 16, height: 16, borderRadius: 8,
+ borderWidth: 1.5, borderColor: Colors.bgDeep2,
+ justifyContent: 'center', alignItems: 'center',
+ },
+ memberName: { color: Colors.textMuted, fontSize: 10.5, fontWeight: '600', marginTop: 5, textAlign: 'center' },
+ bonusChip: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ height: 38, width: '100%', borderRadius: 12,
+ backgroundColor: Colors.primary, marginBottom: 12,
+ },
+ bonusChipTxt: { color: '#fff', fontSize: 12.5, fontWeight: '800' },
+ inviteToggle: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ height: 42, width: '100%', borderRadius: 12,
+ borderWidth: 1, borderColor: Colors.borderSubtle,
+ backgroundColor: 'rgba(255,255,255,0.03)', marginBottom: 8, gap: 6,
+ },
+ inviteToggleTxt: { color: Colors.textPrimary, fontSize: 13, fontWeight: '700' },
+ invitePanel: { width: '100%', marginBottom: 12 },
+ inviteEmpty: { color: Colors.textMuted, fontSize: 12, textAlign: 'center', paddingVertical: 10 },
+ friendRow: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
+ paddingVertical: 10, paddingHorizontal: 4,
+ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)',
+ },
+ friendName: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' },
+ cooldownCircle: {
+ width: 20, height: 20, borderRadius: 10,
+ borderWidth: 1.5, borderColor: Colors.textMuted,
+ justifyContent: 'center', alignItems: 'center',
+ },
+ cooldownTxt: { color: Colors.textMuted, fontSize: 9.5, fontWeight: '700' },
+ readyBtn: {
+ flexDirection: 'row', width: '100%', height: 50, borderRadius: 13,
+ justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.primary,
+ marginTop: 4, shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4, shadowRadius: 12, elevation: 6,
+ },
+ unreadyBtn: {
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.16)',
+ shadowColor: 'transparent', shadowOpacity: 0, elevation: 0,
+ },
+ readyBtnDisabled: {
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ shadowColor: 'transparent', shadowOpacity: 0, elevation: 0,
+ },
+ readyBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
+ waitHint: { color: Colors.textMuted, fontSize: 11, textAlign: 'center', marginTop: 10 },
+
+ // ── BonusTableModal ──────────────────────────────────────────────────────
+ tableCard: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}38`,
+ padding: 24,
+ paddingTop: 36,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.65,
+ shadowRadius: 32,
+ elevation: 20,
+ },
+ tableRows: { width: '100%', gap: 8 },
+ tableRow: {
+ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
+ paddingVertical: 10, paddingHorizontal: 14, borderRadius: 10,
+ backgroundColor: 'rgba(255,255,255,0.03)',
+ },
+ tableRowActive: {
+ backgroundColor: `${Colors.primary}1A`,
+ borderWidth: 1, borderColor: `${Colors.primary}45`,
+ },
+ tableRowLabel: { color: Colors.textSecondary, fontSize: 13.5, fontWeight: '600' },
+ tableRowLabelActive: { color: Colors.textPrimary, fontWeight: '800' },
+ tableRowPercent: { color: Colors.textMuted, fontSize: 14, fontWeight: '800' },
+ tableRowPercentActive: { color: Colors.primary },
+});
diff --git a/front/src/components/workouts/MultiLootModal.js b/front/src/components/workouts/MultiLootModal.js
new file mode 100644
index 0000000..88528cf
--- /dev/null
+++ b/front/src/components/workouts/MultiLootModal.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+
+// ─── MultiLootModal ────────────────────────────────────────────────────────────
+// Popup d'équipe explosive (Section VII) : affichée dès que le dernier membre
+// du lobby termine sa séance — le salon passe 'completed', tout le monde se
+// déverrouille simultanément et voit le bonus XP Multi appliqué.
+//
+// Props :
+// visible bool
+// memberCount number
+// bonusPercent number (0..0.50)
+// bonusXp number — montant XP réellement crédité
+// onClose () => void
+
+export default function MultiLootModal({ visible, memberCount, bonusPercent, bonusXp, onClose }) {
+ return (
+
+
+
+
+
+
+
+ SÉANCE D'ÉQUIPE TERMINÉE
+ Butin distribué !
+
+ Vous avez terminé cette séance à {memberCount} - l'effort collectif paie.
+
+
+
+
+
+ Bonus Multi +{Math.round(bonusPercent * 100)}% {bonusXp != null ? `(+${bonusXp} XP)` : ''}
+
+
+
+
+ Nickel !
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.85)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 24,
+ borderWidth: 1,
+ borderColor: 'rgba(255,215,0,0.4)',
+ padding: 28,
+ alignItems: 'center',
+ shadowColor: Colors.gold,
+ shadowOffset: { width: 0, height: 20 },
+ shadowOpacity: 0.5,
+ shadowRadius: 40,
+ elevation: 24,
+ },
+ iconWrap: {
+ width: 76, height: 76, borderRadius: 24,
+ backgroundColor: 'rgba(255,215,0,0.12)',
+ borderWidth: 1, borderColor: 'rgba(255,215,0,0.4)',
+ justifyContent: 'center', alignItems: 'center', marginBottom: 16,
+ },
+ eyebrow: { color: Colors.textMuted, fontSize: 11, fontWeight: '800', letterSpacing: 1.5, marginBottom: 6 },
+ title: { color: Colors.textPrimary, fontSize: 21, fontWeight: '900', marginBottom: 10, textAlign: 'center' },
+ body: { color: Colors.textSecondary, fontSize: 13.5, lineHeight: 20, textAlign: 'center', marginBottom: 20 },
+ bonusChip: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: Colors.primary, borderRadius: 13,
+ paddingVertical: 12, paddingHorizontal: 18, marginBottom: 22, width: '100%',
+ shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4, shadowRadius: 12, elevation: 6,
+ },
+ bonusChipTxt: { color: '#fff', fontSize: 14.5, fontWeight: '800' },
+ closeBtn: {
+ width: '100%', height: 50, borderRadius: 13,
+ justifyContent: 'center', alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)',
+ },
+ closeBtnTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' },
+});
diff --git a/front/src/components/workouts/MuscleHierarchyPicker.js b/front/src/components/workouts/MuscleHierarchyPicker.js
index 5b23814..eca1daf 100644
--- a/front/src/components/workouts/MuscleHierarchyPicker.js
+++ b/front/src/components/workouts/MuscleHierarchyPicker.js
@@ -64,7 +64,7 @@ function GroupRow({ group, expanded, onToggleExpand, mode, selected, onChange, e
activeOpacity={0.85}
>
- {icon}
+
{group.label}
@@ -168,7 +168,7 @@ const styles = StyleSheet.create({
marginBottom: 10,
overflow: 'hidden',
borderWidth: 1,
- borderColor: '#1f1f27',
+ borderColor: Colors.borderSubtle,
},
groupCardActive: {
borderColor: 'rgba(254, 116, 57, 0.5)',
@@ -183,7 +183,7 @@ const styles = StyleSheet.create({
width: 42,
height: 42,
borderRadius: 10,
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
diff --git a/front/src/components/workouts/SaveWorkoutPromptModal.js b/front/src/components/workouts/SaveWorkoutPromptModal.js
new file mode 100644
index 0000000..ac8ae0b
--- /dev/null
+++ b/front/src/components/workouts/SaveWorkoutPromptModal.js
@@ -0,0 +1,161 @@
+import React from 'react';
+import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+
+// ─── SaveWorkoutPromptModal ───────────────────────────────────────────────────
+// Interception du lancement d'une séance sur-mesure (WorkoutBuilderScreen) :
+// avant d'ouvrir le chrono, propose de sauvegarder la séance générée comme
+// template réutilisable. Deux CTA de poids égal (contrairement à
+// ConfirmModal/InfoModal qui n'ont qu'une seule action principale) + une
+// icône de fermeture pour annuler sans choisir.
+//
+// Props :
+// visible bool
+// onSaveAndLaunch () => void — sauvegarde le template PUIS lance la séance
+// onLaunchWithoutSave () => void — lance directement, aucun template créé
+// onDismiss () => void — ferme la popup sans lancer la séance
+// saving bool — désactive les actions pendant la sauvegarde
+
+export default function SaveWorkoutPromptModal({
+ visible, onSaveAndLaunch, onLaunchWithoutSave, onDismiss, saving = false,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Sauvegarder cette séance ?
+
+ Souhaites-tu sauvegarder cette séance sur-mesure pour la retrouver plus tard ?
+
+
+
+
+
+ {saving ? 'Sauvegarde...' : 'Sauvegarder et lancer la séance'}
+
+
+
+
+
+ Lancer sans sauvegarder
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.82)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}38`,
+ padding: 28,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.65,
+ shadowRadius: 32,
+ elevation: 20,
+ },
+ closeBtn: {
+ position: 'absolute',
+ top: 16,
+ right: 16,
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: Colors.glassBg,
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1,
+ },
+ iconWrap: {
+ width: 60,
+ height: 60,
+ borderRadius: 18,
+ borderWidth: 1,
+ borderColor: `${Colors.primary}45`,
+ backgroundColor: `${Colors.primary}1A`,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 18,
+ },
+ title: {
+ color: Colors.textPrimary,
+ fontSize: 18,
+ fontWeight: '800',
+ letterSpacing: -0.3,
+ marginBottom: 10,
+ textAlign: 'center',
+ },
+ body: {
+ color: Colors.textSecondary,
+ fontSize: 14,
+ lineHeight: 21,
+ textAlign: 'center',
+ marginBottom: 24,
+ },
+ primaryBtn: {
+ flexDirection: 'row',
+ width: '100%',
+ height: 50,
+ borderRadius: 13,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 10,
+ backgroundColor: Colors.primary,
+ shadowColor: Colors.primary,
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.40,
+ shadowRadius: 12,
+ elevation: 6,
+ },
+ primaryTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
+ secondaryBtn: {
+ flexDirection: 'row',
+ width: '100%',
+ height: 50,
+ borderRadius: 13,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'transparent',
+ borderWidth: 1,
+ borderColor: Colors.borderDim,
+ },
+ secondaryTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
+ btnDisabled: { opacity: 0.5 },
+});
diff --git a/front/src/components/workouts/SetTable.js b/front/src/components/workouts/SetTable.js
index d370c6c..a38c5d9 100644
--- a/front/src/components/workouts/SetTable.js
+++ b/front/src/components/workouts/SetTable.js
@@ -1,34 +1,82 @@
-import React from 'react';
+import React, { useCallback, useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import SetRow from '../cards/SetRow';
import { Colors } from '../../constants/theme';
-// Tableau pixel-perfect SET | POIDS (KG) | REPS | ✓ (maquette 2).
-// Colonnes alignées via les mêmes flex/width que SetRow pour une symétrie parfaite.
+// Tableau des séries : [-] SET | POIDS (KG) | REPS | VALIDER
//
// Props :
-// - sets : array<{ weight, reps, completed }>
-// - onToggle(setIndex) : tape sur la checkbox
-// - onChange(setIndex, { weight, reps }) : édite poids ou reps
+// - sets : array<{ weight, reps, completed }>
+// - onToggle : (setIndex) => void
+// - onChange : (setIndex, { weight, reps }) => void
+// - onRemove : (setIndex) => void | undefined
+// - compact : bool – zéro marge/radius pour InlineExerciseBlock
//
+// Autocomplétion :
+// Quand le SET 1 a weight > 0 ET reps > 0 simultanément (i.e. les deux
+// champs sont remplis), les valeurs sont copiées dans les sets suivants
+// qui sont encore vides (weight = 0 ou '' ET reps = 0 ou '').
+// Ces valeurs sont RÉELLES (stockées en state), pas des placeholders.
+
function HeaderCell({ children, style, align = 'center' }) {
return (
-
+
{children}
);
}
-export default function SetTable({ sets = [], onToggle, onChange }) {
+export default function SetTable({ sets = [], onToggle, onChange, onRemove, compact = false }) {
+ const canRemove = !!onRemove && sets.length > 1;
+
+ // Mémorise les valeurs précédentes auto-remplies pour propager chaque frappe
+ // (sans ça, dès que les sets suivants ont reps=3, isEmpty=false bloque la mise
+ // à jour suivante quand l'utilisateur tape "0" pour finir "30").
+ const lastAutoFill = useRef({ weight: 0, reps: 0 });
+
+ const handleChange = useCallback((i, patch) => {
+ if (!onChange) return;
+ onChange(i, patch);
+
+ if (i !== 0) return;
+
+ const newWeight = Number(patch.weight) || 0;
+ const newReps = Number(patch.reps) || 0;
+ if (newWeight <= 0 || newReps <= 0) return;
+
+ const prev = lastAutoFill.current;
+
+ sets.forEach((s, idx) => {
+ if (idx === 0 || s.completed) return;
+ const sw = Number(s.weight) || 0;
+ const sr = Number(s.reps) || 0;
+ const isEmpty = sw === 0 && sr === 0;
+ // Aussi mettre à jour si ce set a été auto-rempli au keystroke précédent
+ const wasAutoFilled = sw === prev.weight && sr === prev.reps && (prev.weight > 0 || prev.reps > 0);
+ if (!isEmpty && !wasAutoFilled) return;
+ onChange(idx, { weight: newWeight, reps: newReps });
+ });
+
+ lastAutoFill.current = { weight: newWeight, reps: newReps };
+ }, [onChange, sets]);
+
return (
-
+
+
+ {/* ── En-tête ──────────────────────────────────────────────────── */}
+
SET
POIDS (KG)
REPS
+ {/* ── Lignes ───────────────────────────────────────────────────── */}
{sets.length === 0 ? (
Aucune série pour cet exercice
@@ -40,10 +88,12 @@ export default function SetTable({ sets = [], onToggle, onChange }) {
index={i}
setData={s}
onToggle={() => onToggle && onToggle(i)}
- onChange={(patch) => onChange && onChange(i, patch)}
+ onChange={(patch) => handleChange(i, patch)}
+ onRemove={canRemove ? () => onRemove(i) : null}
/>
))
)}
+
);
}
@@ -57,6 +107,12 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
overflow: 'hidden',
},
+ tableCompact: {
+ marginHorizontal: 0,
+ borderRadius: 0,
+ borderWidth: 0,
+ },
+
headerRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -69,14 +125,19 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
headerText: {
- color: '#8B95A3',
+ color: Colors.textDim,
fontSize: 11,
fontWeight: '700',
letterSpacing: 1,
},
+
+ // Colonnes — alignées avec celles de SetRow
+ colDel: {
+ width: 36,
+ },
colSet: {
flex: 1,
- paddingLeft: 18,
+ paddingLeft: 4,
},
colWeight: {
flex: 2,
@@ -89,6 +150,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
+
empty: {
paddingVertical: 22,
alignItems: 'center',
diff --git a/front/src/components/workouts/ShortSessionWarningModal.js b/front/src/components/workouts/ShortSessionWarningModal.js
index 16bb23d..e4a2fd1 100644
--- a/front/src/components/workouts/ShortSessionWarningModal.js
+++ b/front/src/components/workouts/ShortSessionWarningModal.js
@@ -80,7 +80,7 @@ const styles = StyleSheet.create({
},
card: {
width: '100%',
- backgroundColor: '#13131C',
+ backgroundColor: Colors.bgDeep2,
borderRadius: 24,
borderWidth: 1,
borderColor: 'rgba(255,77,77,0.28)',
@@ -88,7 +88,7 @@ const styles = StyleSheet.create({
paddingTop: 32,
paddingBottom: 28,
alignItems: 'center',
- shadowColor: '#FF4D4D',
+ shadowColor: Colors.error,
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.25,
shadowRadius: 28,
diff --git a/front/src/components/workouts/WorkoutHeader.js b/front/src/components/workouts/WorkoutHeader.js
deleted file mode 100644
index 41ce9b0..0000000
--- a/front/src/components/workouts/WorkoutHeader.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import { View, Text, StyleSheet } from 'react-native';
-import { Colors } from '../../constants/theme';
-
-// Logo ATHLY (mark + wordmark) pour rester pixel-perfect avec la maquette.
-function AthlyLogo() {
- return (
-
- △
- ATHLY
-
- );
-}
-
-export default function WorkoutHeader({ title = 'Séance', subtitle = null }) {
- return (
-
-
- {title}
- {subtitle ? {subtitle} : null}
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 20,
- paddingTop: 40,
- paddingBottom: 14,
- backgroundColor: Colors.background,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
- },
- titleBlock: {
- flex: 1,
- paddingRight: 12,
- },
- title: {
- color: Colors.textPrimary,
- fontSize: 26,
- fontWeight: '700',
- letterSpacing: 0.2,
- },
- subtitle: {
- color: Colors.textSecondary,
- fontSize: 13,
- marginTop: 4,
- },
- logoWrap: {
- alignItems: 'center',
- justifyContent: 'center',
- paddingHorizontal: 6,
- },
- logoMark: {
- color: Colors.primary,
- fontSize: 26,
- lineHeight: 28,
- fontWeight: '900',
- marginBottom: 2,
- },
- logoText: {
- color: Colors.primary,
- fontSize: 11,
- fontWeight: '900',
- letterSpacing: 1.5,
- },
-});
diff --git a/front/src/components/workouts/WorkoutRecapModal.js b/front/src/components/workouts/WorkoutRecapModal.js
index 983cb48..870a09a 100644
--- a/front/src/components/workouts/WorkoutRecapModal.js
+++ b/front/src/components/workouts/WorkoutRecapModal.js
@@ -16,8 +16,8 @@ const SCREEN_H = Dimensions.get('window').height;
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Colors } from '../../constants/theme';
-import { xpToLevel, getRank } from '../../services/stats.service';
-import { QUEST_XP, BONUS_XP } from '../../services/quest.service';
+import { xpToLevel, getRank } from '../../services';
+import { QUEST_XP, BONUS_XP } from '../../services';
const LOGO_VIOLET = require('../../../assets/logo-violet.png');
@@ -441,7 +441,7 @@ export default function WorkoutRecapModal({
- Limite quotidienne atteinte — reviens demain !
+ Limite quotidienne atteinte - reviens demain !
>
) : (
@@ -464,13 +464,13 @@ export default function WorkoutRecapModal({
{streakBonusXP > 0 && (
Bonus Régularité ×{xpMultiplier}
- +{streakBonusXP.toLocaleString('fr-FR')} XP
+ +{streakBonusXP.toLocaleString('fr-FR')} XP
)}
{questXPEarned > 0 && (
Bonus Quêtes
- +{questXPEarned.toLocaleString('fr-FR')} XP
+ +{questXPEarned.toLocaleString('fr-FR')} XP
)}
@@ -530,7 +530,7 @@ export default function WorkoutRecapModal({
{pr.name}
- {pr.oldPR || '—'} kg
+ {pr.oldPR || '-'} kg
→
{pr.newPR} kg
@@ -557,9 +557,9 @@ export default function WorkoutRecapModal({
))}
{bonusUnlocked ? (
-
- Bonus toutes quêtes
- +{BONUS_XP} XP
+
+ Bonus toutes quêtes
+ +{BONUS_XP} XP
) : null}
diff --git a/front/src/constants/exerciseFilters.js b/front/src/constants/exerciseFilters.js
index 624373c..040e2db 100644
--- a/front/src/constants/exerciseFilters.js
+++ b/front/src/constants/exerciseFilters.js
@@ -6,7 +6,7 @@ export const MUSCLE_GROUPS = [
{
id: 'pectoraux',
label: 'Pectoraux',
- icon: '💪',
+ icon: 'barbell-outline',
subMuscles: [
{ id: 'pectoraux-haut', label: 'Pectoraux haut' },
{ id: 'pectoraux-milieu', label: 'Pectoraux milieu' },
@@ -16,7 +16,7 @@ export const MUSCLE_GROUPS = [
{
id: 'dos',
label: 'Dos',
- icon: '💪',
+ icon: 'barbell-outline',
subMuscles: [
{ id: 'grand-dorsal', label: 'Grand dorsal' },
{ id: 'rhomboides', label: 'Rhomboïdes' },
@@ -27,7 +27,7 @@ export const MUSCLE_GROUPS = [
{
id: 'epaules',
label: 'Épaules',
- icon: '💪',
+ icon: 'barbell-outline',
subMuscles: [
{ id: 'deltoide-anterieur', label: 'Deltoïde antérieur' },
{ id: 'deltoide-lateral', label: 'Deltoïde latéral' },
@@ -37,7 +37,7 @@ export const MUSCLE_GROUPS = [
{
id: 'bras',
label: 'Bras',
- icon: '💪',
+ icon: 'barbell-outline',
subMuscles: [
{ id: 'biceps', label: 'Biceps' },
{ id: 'triceps', label: 'Triceps' },
@@ -47,7 +47,7 @@ export const MUSCLE_GROUPS = [
{
id: 'jambes',
label: 'Jambes',
- icon: '🦵',
+ icon: 'walk-outline',
subMuscles: [
{ id: 'quadriceps', label: 'Quadriceps' },
{ id: 'ischios', label: 'Ischios' },
@@ -59,7 +59,7 @@ export const MUSCLE_GROUPS = [
{
id: 'abdos',
label: 'Abdos',
- icon: '💪',
+ icon: 'barbell-outline',
subMuscles: [
{ id: 'grand-droit', label: 'Grand droit' },
{ id: 'obliques', label: 'Obliques' },
@@ -96,16 +96,17 @@ export const EQUIPMENTS = [
{ id: 'machine', label: 'Machine' },
];
-// Glyph affiché dans la card (cohérent avec la maquette : 💪 par défaut, 🔥 pour le poids du corps).
+// Icône Ionicons affichée dans la card (barbell-outline par défaut, body-outline
+// pour le poids du corps — pas de matériel, juste le corps).
export const ICON_FOR_EQUIPMENT = {
- 'poids-du-corps': '🔥',
+ 'poids-du-corps': 'body-outline',
};
export const ICON_FOR_MUSCLE_GROUP = {
- jambes: '🦵',
+ jambes: 'walk-outline',
};
-export const DEFAULT_ICON = '💪';
+export const DEFAULT_ICON = 'barbell-outline';
// Normalise une chaîne libre (accents, casse) en id stable.
export function normalizeId(value) {
diff --git a/front/src/constants/theme.js b/front/src/constants/theme.js
index ec6b3bf..4142b4a 100644
--- a/front/src/constants/theme.js
+++ b/front/src/constants/theme.js
@@ -38,10 +38,23 @@ export const Colors = {
rankViolet: '#8B5CF6', // rang Elite
warningAmber: '#F59E0B', // états d'avertissement (auth)
+ // ── Rareté UNIQUE — Rouge Sang premium ─────────────────────────────────────
+ // Identité visuelle dédiée aux cosmétiques/trophées de rareté Unique
+ // (cadre "Lien de Sang", couleur "Rouge Sang", thème de profil…).
+ // uniqueBlood : couleur de base (bordures, texte, icônes)
+ // uniqueBloodBright: reflet clair (dégradés, sheen animé)
+ // uniqueBloodDeep : ombre profonde (bas de dégradé, fonds teintés)
+ // uniqueBloodGlow : halo/lueur (shadowColor, glow pulsé)
+ uniqueBlood: '#A30000',
+ uniqueBloodBright: '#FF2E4D',
+ uniqueBloodDeep: '#3D0000',
+ uniqueBloodGlow: 'rgba(163,0,0,0.65)',
+
// ── Feedback ───────────────────────────────────────────────────────────────
- valid: '#22C55E', // sets / exercices validés (vert chaud)
- success: '#44FF88', // XP / gains (vert néon)
- error: '#FF4D4D',
+ valid: '#22C55E', // sets / exercices validés (vert chaud)
+ success: '#44FF88', // XP / gains (vert néon)
+ error: '#FF4D4D',
+ destructive: '#EF4444', // actions destructrices (Supprimer…), accent rouge des modales
// ── Utilitaires ────────────────────────────────────────────────────────────
chevron: '#888888',
diff --git a/front/src/context/AuthContext.js b/front/src/context/AuthContext.js
index fb59a77..9bf85c8 100644
--- a/front/src/context/AuthContext.js
+++ b/front/src/context/AuthContext.js
@@ -1,6 +1,6 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { getToken, removeToken, saveToken, setSessionOnly, getSessionOnly } from '../utils/authStorage';
-import { setSignOutCallback } from '../api/api';
+import { setSignOutCallback, purgeApiCache } from '../api/api';
const AuthContext = createContext();
@@ -37,6 +37,8 @@ export const AuthProvider = ({ children }) => {
const signOut = async () => {
await removeToken();
await setSessionOnly(false);
+ // Aucune donnée du compte ne doit survivre dans le cache API (appareil partagé)
+ await purgeApiCache();
setUserToken(null);
};
diff --git a/front/src/context/CustomExercisesContext.js b/front/src/context/CustomExercisesContext.js
index 48cd449..8bfcd38 100644
--- a/front/src/context/CustomExercisesContext.js
+++ b/front/src/context/CustomExercisesContext.js
@@ -4,7 +4,7 @@ import {
addCustomExercise,
updateCustomExercise,
removeCustomExercise,
-} from '../services/customExercises.service';
+} from '../services';
// Context global pour les exercices personnalisés.
// - Charge la liste depuis AsyncStorage au montage
diff --git a/front/src/context/QuestContext.js b/front/src/context/QuestContext.js
index d15a0f6..e60ea5d 100644
--- a/front/src/context/QuestContext.js
+++ b/front/src/context/QuestContext.js
@@ -12,7 +12,7 @@ import {
getTemplateById,
QUEST_XP,
BONUS_XP,
-} from '../services/quest.service';
+} from '../services';
import { useWorkoutLogs } from './WorkoutLogsContext';
const QuestContext = createContext(null);
diff --git a/front/src/context/SavedWorkoutsContext.js b/front/src/context/SavedWorkoutsContext.js
index da59366..4c69a56 100644
--- a/front/src/context/SavedWorkoutsContext.js
+++ b/front/src/context/SavedWorkoutsContext.js
@@ -4,7 +4,7 @@ import {
saveWorkout,
updateSavedWorkout,
removeSavedWorkout,
-} from '../services/savedWorkouts.service';
+} from '../services';
// Context global pour les séances sauvegardées (favoris / réutilisables).
// Chargement initial depuis AsyncStorage, mise à jour optimiste après chaque action.
diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js
index 70d7bd8..a549e40 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 { CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters';
+import { completeOnboarding } from '../services';
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}
@@ -209,8 +225,17 @@ export function useTutorialTarget(key) {
});
}, [key, registerTarget]);
+ // Plusieurs mesures échelonnées sur ~1s : sur un écran atteint via une
+ // transition de stack (slide horizontal, @react-navigation/stack), la
+ // position mesurée juste après le premier layout peut encore refléter un
+ // état transitoire (mi-glissement, décalé vers la droite) plutôt que la
+ // position de repos finale — measure() renvoie la position réellement
+ // rendue à l'écran, transform en cours inclus. Chaque mesure écrase la
+ // précédente (registerTarget), donc la dernière — une fois la transition
+ // calmée — corrige automatiquement le spotlight, quel que soit le
+ // mécanisme ou la durée exacte de la transition.
const onLayout = useCallback(() => {
- setTimeout(measure, 80);
+ [80, 250, 450, 700, 1000].forEach((delay) => setTimeout(measure, delay));
}, [measure]);
return { ref, onLayout, remeasure: measure };
diff --git a/front/src/context/WorkoutInProgressContext.js b/front/src/context/WorkoutInProgressContext.js
index 1853d4e..8807bf7 100644
--- a/front/src/context/WorkoutInProgressContext.js
+++ b/front/src/context/WorkoutInProgressContext.js
@@ -1,8 +1,8 @@
import React, { createContext, useContext, useCallback, useMemo } from 'react';
-import useWorkoutState from '../hooks/useWorkoutState';
+import { useWorkoutState } from '../hooks';
import { useWorkoutLogs } from './WorkoutLogsContext';
import { useQuests } from './QuestContext';
-import { buildLogFromWorkout, findNewPRsInLog } from '../services/stats.service';
+import { buildLogFromWorkout, findNewPRsInLog } from '../services';
// Context global pour la séance en cours.
// Centralise useWorkoutState : tous les écrans consomment le même état + actions
diff --git a/front/src/context/WorkoutLogsContext.js b/front/src/context/WorkoutLogsContext.js
index fdc0c1a..7945e1e 100644
--- a/front/src/context/WorkoutLogsContext.js
+++ b/front/src/context/WorkoutLogsContext.js
@@ -1,5 +1,7 @@
-import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
-import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog } from '../services/stats.service';
+import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { AppState } from 'react-native';
+import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services';
+import { syncXp, retryPendingXpSync } from '../services';
// Context global pour l'historique des séances finalisées (logs).
// Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen.
@@ -10,6 +12,7 @@ export function WorkoutLogsProvider({ children }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const appState = useRef(AppState.currentState);
const refresh = useCallback(async () => {
setLoading(true);
@@ -17,6 +20,9 @@ export function WorkoutLogsProvider({ children }) {
try {
const list = await listLogs();
setItems(list);
+ // Check-up de cohérence au démarrage (Section X) : pousse le total XP
+ // local vers le backend, fire-and-forget — voir xpSync.service.js.
+ syncXp(totalCumulativeXP(list));
} catch (e) {
setError(e && e.message ? e.message : 'Erreur de chargement');
} finally {
@@ -28,10 +34,31 @@ export function WorkoutLogsProvider({ children }) {
refresh();
}, [refresh]);
+ // Pas de détection réseau active dans ce projet (pas de NetInfo) : le retour
+ // au premier plan de l'app est le proxy le plus proche de "le réseau est
+ // peut-être revenu" — ne retente que si un sync précédent a échoué
+ // (isXpSynced: false), pour ne pas spammer l'API à chaque changement d'onglet.
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (nextState) => {
+ if (appState.current.match(/inactive|background/) && nextState === 'active') {
+ retryPendingXpSync(totalCumulativeXP(items));
+ }
+ appState.current = nextState;
+ });
+ return () => subscription.remove();
+ }, [items]);
+
const create = useCallback(async (log) => {
const item = await addLog(log);
// Insertion en tête (logs triés par date desc)
- setItems((prev) => [item, ...prev]);
+ let newTotal;
+ setItems((prev) => {
+ const next = [item, ...prev];
+ newTotal = totalCumulativeXP(next);
+ return next;
+ });
+ // Événement clé (Section X) : fin de séance (solo ou Multi) — sync fire-and-forget.
+ syncXp(newTotal);
return item;
}, []);
@@ -42,15 +69,16 @@ export function WorkoutLogsProvider({ children }) {
const totalXP = useMemo(() => totalCumulativeXP(items), [items]);
- // sessionLogs : séances uniquement (pas de quêtes, pas de rituels) — pour l'historique.
+ // sessionLogs : séances uniquement (pas de quêtes, pas de rituels, pas de bonus) — pour l'historique.
const sessionLogs = useMemo(
- () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'ritual'),
+ () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'ritual' && l.type !== 'item_bonus'),
[items],
);
- // activityLogs : toute activité valide pour le streak (pas de quêtes, pas de shortSession).
+ // activityLogs : toute activité valide pour le streak (pas de quêtes, pas de bonus, pas de shortSession).
+ // item_bonus (objet consommé, bonus de groupe) ne doit jamais compter comme une séance.
const activityLogs = useMemo(
- () => items.filter((l) => l.type !== 'quest_reward' && !l.shortSession),
+ () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'item_bonus' && !l.shortSession),
[items],
);
@@ -62,13 +90,36 @@ export function WorkoutLogsProvider({ children }) {
const addRitual = useCallback(async (ritualId, ritualLabel, durationSeconds, xpEarned) => {
const item = await addRitualLog(ritualId, ritualLabel, durationSeconds, xpEarned);
if (!item) return null;
- setItems((prev) => [item, ...prev]);
+ let newTotal;
+ setItems((prev) => {
+ const next = [item, ...prev];
+ newTotal = totalCumulativeXP(next);
+ return next;
+ });
+ syncXp(newTotal);
+ return item;
+ }, []);
+
+ // Synchronise l'XP/niveau affiché localement (Profil, Accueil) avec un gain
+ // accordé côté backend hors séance : objet d'inventaire consommé, bonus de
+ // streak de groupe... Voir addBonusXpLog (stats.service.js).
+ const addBonusXp = useCallback(async (source, xpEarned) => {
+ const item = await addBonusXpLog(source, xpEarned);
+ if (!item) return null;
+ let newTotal;
+ setItems((prev) => {
+ const next = [item, ...prev];
+ newTotal = totalCumulativeXP(next);
+ return next;
+ });
+ // Événement clé (Section X) : bonus XP (ex: bonus de groupe Multi) — sync fire-and-forget.
+ syncXp(newTotal);
return item;
}, []);
const value = useMemo(
- () => ({ items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, totalXP, clearAll }),
- [items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, totalXP, clearAll],
+ () => ({ items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, addBonusXp, totalXP, clearAll }),
+ [items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, addBonusXp, totalXP, clearAll],
);
return (
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/backendTrophyCategories.js b/front/src/data/backendTrophyCategories.js
new file mode 100644
index 0000000..a871f79
--- /dev/null
+++ b/front/src/data/backendTrophyCategories.js
@@ -0,0 +1,10 @@
+// Catégorie ajoutée pour les trophées backend V2 sans équivalent local direct
+// (collection d'objets par rareté + paliers de coffres ouverts). Partagé
+// entre la Salle des Trophées et le panneau God Mode (Réglages) pour que les
+// deux vues du catalogue combiné restent cohérentes.
+export const COLLECTION_CATEGORY = { id: 'collection', label: 'Collection', icon: 'cube', color: '#3B82F6' };
+
+// Les trophées "profil"/"social" du backend (anniversaire, parrainage, lien
+// d'amitié niveau 5) rejoignent les catégories locales équivalentes ; la
+// collection (rareté d'objets + coffres) forme sa propre catégorie.
+export const BACKEND_CATEGORY_MAP = { profile: 'special', social: 'social', collection: 'collection' };
diff --git a/front/src/data/exerciseCatalog.js b/front/src/data/exerciseCatalog.js
index 1c05a29..317d3cf 100644
--- a/front/src/data/exerciseCatalog.js
+++ b/front/src/data/exerciseCatalog.js
@@ -133,7 +133,6 @@ export const BUILTIN_CATALOG = [
exo('Élévations latérales unilatérales poulie', 'epaules', 'Deltoïde latéral', [], ['Câble'], 'intermediaire'),
exo('Oiseau haltères', 'epaules', 'Deltoïde postérieur', ['Trapèzes', 'Rhomboïdes'], ['Haltères'], 'intermediaire', { videoQuery: 'oiseau haltères deltoïde postérieur' }),
exo('Oiseau buste penché', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Haltères'], 'intermediaire'),
- exo('Oiseau machine', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'),
exo('Reverse pec deck', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'),
exo('Face pull', 'epaules', 'Deltoïde postérieur', ['Trapèzes', 'Rhomboïdes'], ['Câble'], 'debutant'),
exo('Rowing menton', 'epaules', 'Deltoïde latéral', ['Trapèzes'], ['Barre'], 'intermediaire'),
@@ -162,6 +161,7 @@ export const BUILTIN_CATALOG = [
exo('Triceps poulie corde', 'bras', 'Triceps', [], ['Câble'], 'debutant'),
exo('Triceps poulie inversé', 'bras', 'Triceps', [], ['Câble'], 'debutant'),
exo('Dips', 'bras', 'Triceps', ['Pectoraux bas', 'Deltoïde antérieur'], ['Poids du corps'], 'intermediaire'),
+ exo('Dips assistés machine', 'bras', 'Triceps', ['Pectoraux bas', 'Deltoïde antérieur'], ['Machine'], 'debutant'),
exo('Dips bench', 'bras', 'Triceps', ['Deltoïde antérieur'], ['Poids du corps'], 'debutant'),
exo('Pompes diamant', 'bras', 'Triceps', ['Pectoraux milieu'], ['Poids du corps'], 'intermediaire'),
exo('Kickback haltère', 'bras', 'Triceps', [], ['Haltères'], 'debutant'),
@@ -186,7 +186,7 @@ export const BUILTIN_CATALOG = [
exo('Step-up', 'jambes', 'Quadriceps', ['Fessiers'], ['Haltères'], 'debutant'),
exo('Wall sit', 'jambes', 'Quadriceps', [], ['Poids du corps'], 'debutant'),
exo('Leg extension', 'jambes', 'Quadriceps', [], ['Machine'], 'debutant'),
- exo('Presse à cuisses', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'),
+ exo('Leg Press', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'),
exo('Presse à cuisses inclinée', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'),
exo('Fentes marchées', 'jambes', 'Quadriceps', ['Fessiers', 'Ischios'], ['Haltères'], 'intermediaire'),
exo('Fentes inversées', 'jambes', 'Quadriceps', ['Fessiers'], ['Haltères'], 'debutant'),
@@ -348,7 +348,6 @@ export const BUILTIN_CATALOG = [
exo('Around the world plank', 'abdos', 'Transverse', ['Obliques'], ['Poids du corps'], 'intermediaire'),
// ─── Variantes complémentaires ────────────────────────────────────────── (+12)
- exo('Pec deck inversé', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'),
exo('Développé poitrine machine convergente', 'pectoraux', 'Pectoraux milieu', ['Triceps'], ['Machine'], 'debutant'),
exo('Pull-down poignée corde', 'dos', 'Grand dorsal', ['Biceps'], ['Câble'], 'debutant'),
exo('Tractions assistées machine', 'dos', 'Grand dorsal', ['Biceps'], ['Machine'], 'debutant'),
@@ -445,7 +444,6 @@ export const BUILTIN_CATALOG = [
exo('Walking lunge poids du corps', 'jambes', 'Quadriceps', ['Fessiers', 'Ischios'], ['Poids du corps'], 'debutant'),
exo('Step-up latéral', 'jambes', 'Fessiers', ['Quadriceps'], ['Haltères'], 'debutant'),
exo('Romanian deadlift unilatéral haltère', 'jambes', 'Ischios', ['Fessiers'], ['Haltères'], 'avance'),
- exo('Single leg leg press', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'),
exo('Single leg curl debout', 'jambes', 'Ischios', [], ['Machine'], 'debutant'),
exo('Goblet squat plate', 'jambes', 'Quadriceps', ['Fessiers'], ['Poids du corps'], 'debutant'),
exo('Cable squat', 'jambes', 'Quadriceps', ['Fessiers'], ['Câble'], 'debutant'),
diff --git a/front/src/data/friendshipTitles.js b/front/src/data/friendshipTitles.js
new file mode 100644
index 0000000..1618f79
--- /dev/null
+++ b/front/src/data/friendshipTitles.js
@@ -0,0 +1,18 @@
+// Titres de Relation (Titres d'Amitié) — cosmétique textuel affiché sous le
+// niveau d'amitié entre deux amis. Purement déclaratif côté front : le niveau
+// d'amitié (1-5) est calculé par le backend (voir groupStreak.controller.js
+// → computeFriendshipLevel), ce fichier ne fait que le traduire en libellé.
+
+export const FRIENDSHIP_TITLES = {
+ 1: { label: 'Nouveaux Partenaires' },
+ 2: { label: 'Assisteurs de Confiance' },
+ 3: { label: 'Frères de Fonte' },
+ 4: { label: 'Rivaux Légendaires' },
+ 5: { label: 'Âmes Sœurs de Muscle' },
+};
+
+/** Renvoie { label } pour un niveau d'amitié donné (1-5, clampé). */
+export function getFriendshipTitle(level) {
+ const clamped = Math.min(5, Math.max(1, Number(level) || 1));
+ return FRIENDSHIP_TITLES[clamped];
+}
diff --git a/front/src/data/majorExercises.js b/front/src/data/majorExercises.js
index 84eb894..8dba030 100644
--- a/front/src/data/majorExercises.js
+++ b/front/src/data/majorExercises.js
@@ -5,6 +5,9 @@
// Ordre = ordre d'affichage (priorité visuelle décroissante).
// Garde la liste courte (6-8 exos max) pour préserver la lisibilité du profil.
+import { BUILTIN_CATALOG } from './exerciseCatalog';
+import { ICON_FOR_MUSCLE_GROUP, DEFAULT_ICON, normalizeId } from '../constants/exerciseFilters';
+
export const MAJOR_EXERCISES = [
{ name: 'Développé couché', group: 'pectoraux', icon: 'barbell-outline' },
{ name: 'Squat', group: 'jambes', icon: 'barbell-outline' },
@@ -25,3 +28,23 @@ export function findMajorExerciseByName(name) {
return k === target;
}) || null;
}
+
+// Résout { group, icon } pour N'IMPORTE QUEL nom d'exercice (pas seulement les
+// 7 "phares") — utilisé par les records mis en avant, qui peuvent venir de
+// tout le catalogue. Ordre de résolution : MAJOR_EXERCISES (icône curatée) →
+// BUILTIN_CATALOG (groupe musculaire réel, icône déduite) → repli neutre.
+export function resolveExerciseMeta(name) {
+ const major = findMajorExerciseByName(name);
+ if (major) return { group: major.group, icon: major.icon };
+
+ const targetId = normalizeId(name);
+ const catalogMatch = BUILTIN_CATALOG.find((ex) => normalizeId(ex.name) === targetId);
+ if (catalogMatch) {
+ return {
+ group: catalogMatch.targetMuscleGroup,
+ icon: ICON_FOR_MUSCLE_GROUP[catalogMatch.targetMuscleGroup] || DEFAULT_ICON,
+ };
+ }
+
+ return { group: null, icon: DEFAULT_ICON };
+}
diff --git a/front/src/data/profileThemes.js b/front/src/data/profileThemes.js
index 79dcd72..8b02fa1 100644
--- a/front/src/data/profileThemes.js
+++ b/front/src/data/profileThemes.js
@@ -2,6 +2,8 @@
// Each theme overrides accentColor, glow, shimmer and background variant.
// unlockLevel: minimum level required to pick this theme (God Mode bypasses).
+import { Colors } from '../constants/theme';
+
export const PROFILE_THEMES = [
{
id: 'auto',
@@ -124,12 +126,31 @@ export const PROFILE_THEMES = [
hasGlow: true,
bgVariant: 'god',
},
+ {
+ // Cosmétique Unique — débloqué en réclamant l'item THEME_UNLOCK_BLOOD_SANG
+ // (100 coffres ouverts), jamais par le niveau : unlockLevel n'est là que
+ // pour rester cohérent avec le tri, `requiresCosmetic` prime toujours
+ // (voir isThemeLocked et SettingsScreen.js).
+ id: 'blood_sang',
+ name: 'Rouge Sang',
+ description: 'Cramoisi + lueur sang · 100 coffres',
+ unlockLevel: 0,
+ requiresCosmetic: 'THEME_BLOODSANG',
+ requiresChests: 100, // pour le texte de progression avant réclamation
+ accentColor: Colors.uniqueBloodBright,
+ glowColor: Colors.uniqueBloodGlow,
+ shimmer: true,
+ hasGlow: true,
+ bgVariant: 'blood',
+ },
];
export function getTheme(id) {
return PROFILE_THEMES.find((t) => t.id === id) || PROFILE_THEMES[0];
}
-export function isThemeLocked(theme, playerLevel) {
+// unlockedCosmetics : tableau des flags backend (user.unlockedCosmetics).
+export function isThemeLocked(theme, playerLevel, unlockedCosmetics = []) {
+ if (theme.requiresCosmetic) return !unlockedCosmetics.includes(theme.requiresCosmetic);
return playerLevel < theme.unlockLevel;
}
diff --git a/front/src/data/trophyCatalog.js b/front/src/data/trophyCatalog.js
index 8676615..05f31b3 100644
--- a/front/src/data/trophyCatalog.js
+++ b/front/src/data/trophyCatalog.js
@@ -54,19 +54,19 @@ export function weeklyConsecutive(logs, weeks) {
export const TROPHY_CATALOG = [
// ─── HÉRITAGE (6) ──────────────────────────────────────────────────────────
{ id: 'ignition', category: 'heritage', icon: 'flame', label: 'Ignition', condition: '1ère séance',
- epicDesc: "La flamme s'allume. Votre premier pas dans l'arène — et rien ne sera jamais plus pareil.",
+ epicDesc: "La flamme s'allume. Votre premier pas dans l'arène - et rien ne sera jamais plus pareil.",
color: '#FE7439', gradientColors: ['#FF9A5C','#FE7439','#C44A10'], tier: 'bronze',
check: (l, s) => s >= 1 },
{ id: 'promise', category: 'heritage', icon: 'medal', label: 'Promesse', condition: 'Niveau 10',
- epicDesc: "Le novice est mort. Vous avez prouvé que vous êtes là pour durer — la promesse est tenue.",
+ epicDesc: "Le novice est mort. Vous avez prouvé que vous êtes là pour durer - la promesse est tenue.",
color: '#FFD700', gradientColors: ['#FFE566','#FFD700','#B8860B'], tier: 'gold',
check: (l) => l >= 10 },
{ id: 'apprenti', category: 'heritage', icon: 'school', label: 'Apprenti', condition: 'Niveau 25',
- epicDesc: "Les bases sont posées. Chaque set vous a sculpté — vous n'êtes plus un débutant, vous êtes un apprenti.",
+ epicDesc: "Les bases sont posées. Chaque set vous a sculpté - vous n'êtes plus un débutant, vous êtes un apprenti.",
color: '#34D399', gradientColors: ['#6EE7B7','#34D399','#059669'], tier: 'silver',
check: (l) => l >= 25 },
{ id: 'centurion', category: 'heritage', icon: 'trophy', label: 'Centurion', condition: '50 séances',
- epicDesc: "Votre volonté est d'acier. 50 combats menés avec honneur — les légions vous saluent.",
+ epicDesc: "Votre volonté est d'acier. 50 combats menés avec honneur - les légions vous saluent.",
color: '#6E6AF0', gradientColors: ['#9B97FF','#6E6AF0','#3D3A9E'], tier: 'platinum',
check: (l, s) => s >= 50 },
{ id: 'veteran', category: 'heritage', icon: 'shield-checkmark', label: 'Vétéran', condition: 'Niveau 75',
@@ -110,7 +110,7 @@ export const TROPHY_CATALOG = [
// ─── EXPLORATION (6) ───────────────────────────────────────────────────────
{ id: 'polyvalent', category: 'exploration', icon: 'grid', label: 'Polyvalent', condition: '5 groupes musculaires',
- epicDesc: "Pecs, dos, jambes, épaules, bras — vous ne laissez aucun muscle au repos.",
+ epicDesc: "Pecs, dos, jambes, épaules, bras - vous ne laissez aucun muscle au repos.",
color: '#22C55E', gradientColors: ['#4ADE80','#22C55E','#15803D'], tier: 'silver',
check: (l, s, logs) => new Set(logs.flatMap(log => Object.keys(log.muscleDistribution || {}))).size >= 5 },
{ id: 'marathonien', category: 'exploration', icon: 'time', label: 'Marathonien', condition: 'Séance ≥ 60 min',
@@ -118,7 +118,7 @@ export const TROPHY_CATALOG = [
color: '#0EA5E9', gradientColors: ['#38BDF8','#0EA5E9','#0369A1'], tier: 'bronze',
check: (l, s, logs) => logs.some(log => (Number(log.durationSeconds) || 0) >= 3600) },
{ id: 'demi_legende', category: 'exploration', icon: 'rocket', label: 'Demi-Légende', condition: 'Niveau 50',
- epicDesc: "La moitié du chemin vers le sommet. Peu y arrivent — vous y êtes.",
+ epicDesc: "La moitié du chemin vers le sommet. Peu y arrivent - vous y êtes.",
color: '#3B82F6', gradientColors: ['#60A5FA','#3B82F6','#1D4ED8'], tier: 'gold',
check: (l) => l >= 50 },
{ id: 'xp_millionaire', category: 'exploration', icon: 'infinite', label: 'XP Millionnaire', condition: '1 000 000 XP cumulés',
@@ -168,13 +168,13 @@ export const TROPHY_CATALOG = [
color: '#4338CA', gradientColors: ['#6366F1','#4338CA','#1E1B4B'], tier: 'silver',
check: (l, s, logs) => logs.some(log => { if (!log.date) return false; return new Date(log.date).getHours() === 0; }) },
{ id: 'athly_god', category: 'secret', icon: 'planet', label: 'ATHLY GOD', condition: 'Niveau 200',
- epicDesc: "Le sommet absolu. Vous n'êtes plus un athlète — vous êtes une légende vivante. Le trône vous appartient.",
+ epicDesc: "Le sommet absolu. Vous n'êtes plus un athlète - vous êtes une légende vivante. Le trône vous appartient.",
color: '#FFD700', gradientColors: ['#FDE68A','#FFD700','#92400E'], tier: 'diamond',
check: (l) => l >= 200 },
// ─── CORPS (5) ─────────────────────────────────────────────────────────────
{ id: 'corps_bronze', category: 'corps', icon: 'fitness', label: 'Initié Poids Corps', condition: '50 sets complétés',
- epicDesc: "Cinquante séries avec votre propre corps. Pas de barres, pas de charges — juste vous contre la gravité.",
+ epicDesc: "Cinquante séries avec votre propre corps. Pas de barres, pas de charges - juste vous contre la gravité.",
color: '#CD7F32', gradientColors: ['#E8A060','#CD7F32','#6B3A1A'], tier: 'bronze',
check: (l, s, logs) => logs.reduce((sum, log) => sum + (Number(log.setsCompleted) || 0), 0) >= 50 },
{ id: 'corps_silver', category: 'corps', icon: 'walk', label: 'Guerrier Poids Corps', condition: '150 sets complétés',
@@ -196,7 +196,7 @@ export const TROPHY_CATALOG = [
// ─── RÉGULARITÉ (5) ────────────────────────────────────────────────────────
{ id: 'reg_3m', category: 'regularite', icon: 'calendar-outline', label: 'Constance 3 Mois', condition: 'Séances sur 3 mois',
- epicDesc: "Trois mois d'entraînement. Pas une mode — une véritable habitude.",
+ epicDesc: "Trois mois d'entraînement. Pas une mode - une véritable habitude.",
color: '#10B981', gradientColors: ['#34D399','#10B981','#065F46'], tier: 'bronze',
check: (l, s, logs) => daysSpanned(logs) >= 90 },
{ id: 'reg_6m', category: 'regularite', icon: 'time-outline', label: 'Constance 6 Mois', condition: 'Séances sur 6 mois',
@@ -222,13 +222,13 @@ export const TROPHY_CATALOG = [
color: '#EC4899', gradientColors: ['#F472B6','#EC4899','#9D174D'], tier: 'bronze',
check: () => false },
{ id: 'mentor', category: 'social', icon: 'people-circle', label: 'Mentor', condition: 'Inspirer 5 amis',
- epicDesc: "Vous avez allumé la flamme chez cinq autres — vous êtes plus qu'un athlète, vous êtes un mentor.",
+ epicDesc: "Vous avez allumé la flamme chez cinq autres - vous êtes plus qu'un athlète, vous êtes un mentor.",
color: '#8B5CF6', gradientColors: ['#A78BFA','#8B5CF6','#4C1D95'], tier: 'gold',
check: () => false },
// ─── SPÉCIAL (1) ───────────────────────────────────────────────────────────
{ id: 'athly_birthday', category: 'special', icon: 'gift', label: 'Anniversaire Athly', condition: 'Séance le 13 mai',
- epicDesc: "Le jour où Athly est né, vous étiez là — à suer, à pousser, à vous dépasser.",
+ epicDesc: "Le jour où Athly est né, vous étiez là - à suer, à pousser, à vous dépasser.",
color: '#FE7439', gradientColors: ['#FF9A5C','#FE7439','#C44A10'], tier: 'gold',
check: (l, s, logs) => logs.some(log => {
if (!log.date) return false;
@@ -245,7 +245,7 @@ export const ULTIMATE_TROPHY = {
icon: 'infinite',
label: 'Souverain Absolu',
condition: 'Tous les trophées débloqués',
- epicDesc: "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient — et l'univers entier s'incline devant vous.",
+ epicDesc: "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient - et l'univers entier s'incline devant vous.",
color: '#FFD700',
gradientColors: ['#FFFACD', '#FFD700', '#FF8C00', '#C44A10'],
tier: 'diamond',
@@ -264,11 +264,16 @@ export const TROPHY_CATEGORIES = [
{ id: 'special', label: 'Spécial', icon: 'gift', color: '#FE7439' },
];
+// Chaque catégorie (locale + backend via BACKEND_CATEGORY_MAP) doit apparaître
+// dans exactement un onglet ci-dessous — pas de doublon, pas de trou — pour que
+// "Tous" et la somme des onglets filtrés restent toujours cohérents.
export const TROPHY_FILTER_TABS = [
- { id: 'all', label: 'Tous' },
- { id: 'force', label: 'Force', categories: ['force', 'corps'] },
- { id: 'endurance', label: 'Endurance', categories: ['heritage', 'exploration', 'regularite'] },
- { id: 'special', label: 'Spécial', categories: ['secret', 'social', 'special'] },
+ { id: 'all', label: 'Tous' },
+ { id: 'force', label: 'Force', categories: ['force', 'corps'] },
+ { id: 'endurance', label: 'Endurance', categories: ['heritage', 'exploration', 'regularite'] },
+ { id: 'social', label: 'Social & Multi', categories: ['social'] },
+ { id: 'collection', label: 'Coffres & Raretés', categories: ['collection'] },
+ { id: 'special', label: 'Spécial', categories: ['secret', 'special'] },
];
// ─── Helper: évalue le catalogue avec overrides (godMode console) ─────────────
diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js
index 23c72cc..8250797 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,
},
{
@@ -94,7 +94,7 @@ export const TUTORIAL_CHAPTERS = [
{
key: 'workout_anticheat',
title: 'Séances Sérieuses Uniquement',
- body: "Athly récompense l'effort réel : une séance de moins de 5 minutes ne rapporte aucun XP. Tu peux quand même l'enregistrer — mais seules les séances complètes comptent pour ta streak.",
+ body: "Athly récompense l'effort réel : une séance de moins de 5 minutes ne rapporte aucun XP. Tu peux quand même l'enregistrer - mais seules les séances complètes comptent pour ta streak.",
targetKey: null, position: 'center', scrollY: null,
},
{
@@ -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,19 +160,19 @@ 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,
},
{
key: 'trophies_filters',
title: 'Filtrer par Catégorie',
- body: "Les onglets 'Force', 'Endurance' et 'Spécial' filtrent les trophées. Les trophées verrouillés montrent leur condition.",
+ body: "Fais défiler les onglets 'Force', 'Endurance', 'Social & Multi', 'Coffres & Raretés' et 'Spécial' pour filtrer les trophées. Les trophées verrouillés montrent leur condition.",
targetKey: 'trophies_filters', position: 'bottom', scrollY: 0,
},
{
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,101 @@ 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_friendship_titles',
+ title: "Titres d'Amitié",
+ body: "Chaque jour de streak de groupe validé à plusieurs renforce ton lien avec chaque coéquipier. De 'Nouveaux Partenaires' à 'Âmes Sœurs de Muscle' au niveau 5 maximum, un Titre d'Amitié affiche l'histoire que vous avez construite ensemble.",
+ 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 +296,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 +324,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',
@@ -252,7 +354,7 @@ export const TUTORIAL_CHAPTERS = [
{
key: 'settings_done',
title: 'Tu es prêt, Athlète !',
- body: "Tu maîtrises désormais Athly. Lance-toi — chaque set te rapproche du sommet. Bonne chance !",
+ body: "Tu maîtrises désormais Athly. Lance-toi - chaque set te rapproche du sommet. Bonne chance !",
targetKey: null, position: 'center', scrollY: null,
isLast: true,
},
diff --git a/front/src/data/workoutTemplates.js b/front/src/data/workoutTemplates.js
index 419f3e3..8592a50 100644
--- a/front/src/data/workoutTemplates.js
+++ b/front/src/data/workoutTemplates.js
@@ -30,7 +30,7 @@ export const TEMPLATES = [
name: 'Séance Push',
description: 'Pectoraux, épaules, triceps',
musclesSummary: 'Pectoraux • Triceps • Épaules',
- icon: '💪',
+ icon: 'barbell-outline',
estimatedDurationMin: 75,
buildExercises: () => [
buildExercise({
@@ -96,7 +96,7 @@ export const TEMPLATES = [
name: 'Séance Pull',
description: 'Dos, biceps, trapèzes',
musclesSummary: 'Dos • Biceps • Trapèzes',
- icon: '💪',
+ icon: 'fitness-outline',
estimatedDurationMin: 70,
buildExercises: () => [
buildExercise({
@@ -155,7 +155,7 @@ export const TEMPLATES = [
name: 'Séance Jambes',
description: 'Quadriceps, ischios, fessiers',
musclesSummary: 'Quadriceps • Ischios • Fessiers',
- icon: '🦵',
+ icon: 'walk-outline',
estimatedDurationMin: 80,
buildExercises: () => [
buildExercise({
@@ -173,7 +173,7 @@ export const TEMPLATES = [
videoQuery: 'soulevé de terre roumain',
}),
buildExercise({
- name: 'Presse à cuisses',
+ name: 'Leg Press',
targetMuscle: 'Quadriceps',
secondaryMuscles: ['Fessiers'],
equipment: ['Machine'],
@@ -207,7 +207,7 @@ export const TEMPLATES = [
name: 'Full body',
description: 'Tout le corps en une séance',
musclesSummary: 'Pectoraux • Dos • Jambes • Bras',
- icon: '🔥',
+ icon: 'flame-outline',
estimatedDurationMin: 60,
buildExercises: () => [
buildExercise({
diff --git a/front/src/hooks/index.js b/front/src/hooks/index.js
new file mode 100644
index 0000000..ab25cbd
--- /dev/null
+++ b/front/src/hooks/index.js
@@ -0,0 +1,11 @@
+// ─── Barrel des hooks ─────────────────────────────────────────────────────────
+// Point d'entrée unique : `import { useAvatarFrame, useEffortTimer } from '../hooks';`
+// Les hooks à export default sont ré-exportés sous leur nom canonique.
+
+export { useAvatarFrame } from './useAvatarFrame';
+export { useDevSettings } from './useDevSettings';
+export { default as useEffortTimer, formatDuration } from './useEffortTimer';
+export { default as useExerciseSorting, isFiltering } from './useExerciseSorting';
+export { useFeaturedTrophies, MAX_FEATURED } from './useFeaturedTrophies';
+export { useGoogleAuth } from './useGoogleAuth';
+export { default as useWorkoutState } from './useWorkoutState';
diff --git a/front/src/hooks/useAvatarFrame.js b/front/src/hooks/useAvatarFrame.js
index edcc268..11fc0b6 100644
--- a/front/src/hooks/useAvatarFrame.js
+++ b/front/src/hooks/useAvatarFrame.js
@@ -1,5 +1,6 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
+import { updateEquippedFrame } from '../services/profile.service';
const SHAPE_KEY = 'athly:avatarShape:v1';
const COLOR_KEY = 'athly:avatarColor:v1';
@@ -7,6 +8,10 @@ const COLOR_KEY = 'athly:avatarColor:v1';
export function useAvatarFrame() {
const [shapeId, setShapeId] = useState('circle');
const [colorId, setColorId] = useState('none');
+ // Toujours à jour de façon synchrone (contrairement au state React) pour
+ // envoyer la paire {shapeId, colorId} complète au backend, même quand un
+ // seul des deux vient de changer.
+ const latest = useRef({ shapeId: 'circle', colorId: 'none' });
useEffect(() => {
Promise.all([
@@ -15,18 +20,28 @@ export function useAvatarFrame() {
]).then(([shape, color]) => {
if (shape) setShapeId(shape);
if (color) setColorId(color);
+ latest.current = { shapeId: shape || 'circle', colorId: color || 'none' };
}).catch(() => {});
}, []);
+ // Fire-and-forget : la synchro backend ne doit jamais bloquer/casser
+ // l'affichage local (source de vérité pour soi-même).
+ const syncBackend = useCallback((next) => {
+ latest.current = next;
+ updateEquippedFrame(next.shapeId, next.colorId).catch(() => {});
+ }, []);
+
const selectShape = useCallback(async (id) => {
setShapeId(id);
try { await AsyncStorage.setItem(SHAPE_KEY, id); } catch (e) {}
- }, []);
+ syncBackend({ ...latest.current, shapeId: id });
+ }, [syncBackend]);
const selectColor = useCallback(async (id) => {
setColorId(id);
try { await AsyncStorage.setItem(COLOR_KEY, id); } catch (e) {}
- }, []);
+ syncBackend({ ...latest.current, colorId: id });
+ }, [syncBackend]);
return { shapeId, colorId, selectShape, selectColor };
}
diff --git a/front/src/hooks/useFeaturedTrophies.js b/front/src/hooks/useFeaturedTrophies.js
index 03c9d90..b62e8f9 100644
--- a/front/src/hooks/useFeaturedTrophies.js
+++ b/front/src/hooks/useFeaturedTrophies.js
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
+import { updateShowcase } from '../services/profile.service';
const FEATURED_KEY = 'athly:pref:featuredTrophies:v1';
export const MAX_FEATURED = 3;
@@ -28,6 +29,9 @@ export function useFeaturedTrophies() {
next = [...prev.slice(1), trophyId];
}
AsyncStorage.setItem(FEATURED_KEY, JSON.stringify(next)).catch(() => {});
+ // Fire-and-forget : la vitrine locale (soi-même) ne doit jamais dépendre
+ // du réseau. Le backend ne sert qu'à exposer la vitrine aux amis.
+ updateShowcase(next).catch(() => {});
return next;
});
}, []);
diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js
new file mode 100644
index 0000000..5b19d2e
--- /dev/null
+++ b/front/src/hooks/useGoogleAuth.js
@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Platform } from 'react-native';
+import * as Google from 'expo-auth-session/providers/google';
+import * as WebBrowser from 'expo-web-browser';
+import {
+ GOOGLE_EXPO_CLIENT_ID,
+ GOOGLE_IOS_CLIENT_ID,
+ GOOGLE_ANDROID_CLIENT_ID,
+ GOOGLE_WEB_CLIENT_ID,
+} from '@env';
+
+// Ferme proprement l'onglet du navigateur système ouvert par promptAsync()
+// une fois l'auth terminée — sans ça l'onglet reste bloqué en attente sur
+// certaines plateformes (voir la doc expo-auth-session).
+WebBrowser.maybeCompleteAuthSession();
+
+// Clé sessionStorage utilisée pour vérifier l'état retour d'un flux en plein
+// écran (voir plus bas — PWA installée). sessionStorage survit à une
+// navigation complète de la page (contrairement à un state React), ce qui
+// est indispensable ici puisque tout le contexte JS est détruit pendant que
+// Google Sign-In s'affiche.
+const PENDING_STATE_KEY = 'athly:google:pending_state:v1';
+
+function isStandaloneDisplayMode() {
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return false;
+ const mediaStandalone = window.matchMedia?.('(display-mode: standalone)').matches;
+ const iosStandalone = window.navigator?.standalone === true; // Safari iOS n'a pas display-mode
+ return Boolean(mediaStandalone || iosStandalone);
+}
+
+// ─── useGoogleAuth ────────────────────────────────────────────────────────────
+// Connexion Google en un clic (Section VIII) : demande un idToken via le SDK
+// Expo Auth Session, à envoyer ensuite à POST /api/auth/google (vérifié côté
+// serveur — voir back/services/auth.service.js → googleLogin).
+//
+// Les Client IDs viennent de .env (voir .env.example pour la marche à suivre
+// dans Google Cloud Console). Tant qu'ils ne sont pas renseignés, `isConfigured`
+// vaut false et le bouton doit rester désactivé côté écran appelant.
+//
+// ── Cas particulier PWA installée (Section IX) ────────────────────────────
+// expo-auth-session ouvre une popup (window.open + postMessage) pour récupérer
+// le résultat côté web. Ce mécanisme est fragile depuis une PWA en mode
+// standalone (surtout iOS Safari "Ajouter à l'écran d'accueil") : window.open
+// y fait souvent sortir l'utilisateur de l'app installée sans jamais pouvoir
+// relayer le message à la fenêtre d'origine, et la connexion reste bloquée.
+// Dans ce cas précis, on bascule sur une redirection plein écran (navigue
+// loin de l'app, puis revient dessus) : l'idToken est alors récupéré au
+// prochain montage du hook via le hash de l'URL de retour, pas via la Promise
+// de promptAsync (qui ne peut pas survivre au rechargement complet de la page).
+
+export function useGoogleAuth() {
+ const isConfigured = Boolean(
+ GOOGLE_EXPO_CLIENT_ID || GOOGLE_IOS_CLIENT_ID || GOOGLE_ANDROID_CLIENT_ID || GOOGLE_WEB_CLIENT_ID,
+ );
+
+ const [request, response, promptAsync] = Google.useIdTokenAuthRequest({
+ clientId: GOOGLE_EXPO_CLIENT_ID || undefined,
+ iosClientId: GOOGLE_IOS_CLIENT_ID || undefined,
+ androidClientId: GOOGLE_ANDROID_CLIENT_ID || undefined,
+ webClientId: GOOGLE_WEB_CLIENT_ID || undefined,
+ });
+
+ const [redirectIdToken, setRedirectIdToken] = useState(null);
+
+ // Récupère un éventuel idToken laissé dans le hash de l'URL par le flux de
+ // redirection plein écran (PWA standalone) — vérifié une seule fois au
+ // montage, jamais pendant le flux popup normal (le hash n'existe alors pas
+ // sur CETTE page, seulement sur la popup).
+ useEffect(() => {
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return;
+ if (!window.location.hash || !window.location.hash.includes('id_token=')) return;
+
+ let expectedState = null;
+ try { expectedState = window.sessionStorage.getItem(PENDING_STATE_KEY); } catch (_) {}
+
+ const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
+ const returnedIdToken = hashParams.get('id_token');
+ const returnedState = hashParams.get('state');
+
+ // Nettoie systématiquement l'URL — un idToken ne doit jamais rester
+ // visible dans la barre d'adresse ou l'historique du navigateur.
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
+ try { window.sessionStorage.removeItem(PENDING_STATE_KEY); } catch (_) {}
+
+ // Anti-CSRF : le state retourné doit correspondre exactement à celui
+ // généré avant la redirection (voir handlePromptAsync ci-dessous).
+ if (returnedIdToken && expectedState && returnedState === expectedState) {
+ setRedirectIdToken(returnedIdToken);
+ }
+ }, []);
+
+ const idToken = useMemo(() => {
+ if (redirectIdToken) return redirectIdToken;
+ if (!response || response.type !== 'success') return null;
+ return response.authentication?.idToken || response.params?.id_token || null;
+ }, [response, redirectIdToken]);
+
+ const handlePromptAsync = useCallback(async (...args) => {
+ if (isStandaloneDisplayMode() && request?.url && request?.state) {
+ try { window.sessionStorage.setItem(PENDING_STATE_KEY, request.state); } catch (_) {}
+ window.location.assign(request.url);
+ // La navigation détruit ce contexte JS avant toute résolution possible —
+ // le résultat sera repris par l'effet ci-dessus au prochain montage.
+ return { type: 'opened' };
+ }
+ return promptAsync(...args);
+ }, [request, promptAsync]);
+
+ return {
+ isConfigured,
+ request,
+ response,
+ idToken,
+ promptAsync: handlePromptAsync,
+ };
+}
diff --git a/front/src/hooks/useWorkoutState.js b/front/src/hooks/useWorkoutState.js
index 8ea44ca..aeb5cbe 100644
--- a/front/src/hooks/useWorkoutState.js
+++ b/front/src/hooks/useWorkoutState.js
@@ -1,5 +1,5 @@
import { useReducer, useEffect, useRef, useCallback, useMemo } from 'react';
-import API from '../api/api';
+import { createWorkoutDraft, updateWorkoutDraft, finalizeWorkout } from '../services/workouts.service';
// ---------------------------------------------------------------------------
// useWorkoutState
@@ -127,11 +127,21 @@ function reducer(state, action) {
}
case ACTIONS.ADD_EXERCISE: {
- const { exercise } = action.payload || {};
+ const { exercise, defaultSetsCount } = action.payload || {};
if (!exercise) return state;
- const sets = Array.isArray(exercise.sets) && exercise.sets.length > 0
- ? exercise.sets
- : [{}, {}, {}, {}];
+ const count = Number.isInteger(defaultSetsCount) && defaultSetsCount >= 1 ? defaultSetsCount : 4;
+ // Les exos du catalogue arrivent avec sets:[{},{},{}] (objets vides, pas de weight/reps).
+ // On ne les utilise que s'ils ont au moins un set avec des données réelles.
+ const hasMeaningfulSets = Array.isArray(exercise.sets) &&
+ exercise.sets.length > 0 &&
+ exercise.sets.some((s) => s && (s.weight !== undefined || s.reps !== undefined));
+ const sets = hasMeaningfulSets
+ ? exercise.sets.map((s) => ({
+ weight: s.weight ?? 0,
+ reps: s.reps ?? 0,
+ completed: s.completed ?? false,
+ }))
+ : Array.from({ length: count }, () => ({ weight: 0, reps: 0, completed: false }));
const exercises = [
...state.exercises,
{ ...exercise, sets, notes: exercise.notes || '', groupId: exercise.groupId || null },
@@ -219,27 +229,22 @@ export default function useWorkoutState(initial = {}) {
isSaving.current = true;
try {
if (!state.id) {
- const res = await API.post('/workouts/draft', {
+ const workout = await createWorkoutDraft({
name: state.name,
exercises: state.exercises,
notes: state.notes,
- status: 'draft',
});
- const data = res && res.data ? res.data : res;
- const workout = data && data.workout ? data.workout : null;
if (workout) {
dispatch({ type: ACTIONS.SET_WORKOUT, payload: { id: workout._id } });
return workout;
}
return null;
}
- const res = await API.patch(`/workouts/${state.id}/draft`, {
+ return await updateWorkoutDraft(state.id, {
exercises: state.exercises,
notes: state.notes,
durationSeconds: state.durationSeconds,
});
- const data = res && res.data ? res.data : res;
- return data && data.workout ? data.workout : data;
} catch (error) {
// Pas de Alert ici pour éviter le spam — on le centralise dans finalize/handler explicite.
return null;
@@ -257,13 +262,12 @@ export default function useWorkoutState(initial = {}) {
const saved = await saveDraft();
const workoutId = state.id || (saved && (saved._id || saved.id));
if (!workoutId) throw new Error('no_id');
- const res = await API.post(`/workouts/${workoutId}/finalize`, {
+ const data = await finalizeWorkout(workoutId, {
exercises: state.exercises,
notes: state.notes,
durationSeconds: state.durationSeconds,
...options,
});
- const data = res && res.data ? res.data : res;
if (data && data.stats) {
dispatch({ type: ACTIONS.MARK_FINISHED });
return data.stats;
@@ -306,8 +310,8 @@ export default function useWorkoutState(initial = {}) {
debouncedSave();
}, [debouncedSave]);
- const addExercise = useCallback((exercise) => {
- dispatch({ type: ACTIONS.ADD_EXERCISE, payload: { exercise } });
+ const addExercise = useCallback((exercise, defaultSetsCount) => {
+ dispatch({ type: ACTIONS.ADD_EXERCISE, payload: { exercise, defaultSetsCount } });
debouncedSave();
}, [debouncedSave]);
diff --git a/front/src/navigation/BottomTabs.js b/front/src/navigation/BottomTabs.js
index 1117b70..980455a 100644
--- a/front/src/navigation/BottomTabs.js
+++ b/front/src/navigation/BottomTabs.js
@@ -3,6 +3,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import HomeScreen from '../screens/Home/HomeScreen';
import WorkoutStack from './WorkoutStack';
import StatsScreen from '../screens/Stats/StatsScreen';
+import SocialStack from './SocialStack';
import ProfileStack from './ProfileStack';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../constants/theme';
@@ -22,6 +23,7 @@ export default function BottomTabs() {
let icon = 'home';
if (route.name === 'Séances') icon = 'barbell';
if (route.name === 'Stats') icon = 'stats-chart';
+ if (route.name === 'SocialTab') icon = 'people';
if (route.name === 'ProfileTab') icon = 'person';
return ;
},
@@ -30,8 +32,13 @@ export default function BottomTabs() {
-
+
);
-}
\ No newline at end of file
+}
diff --git a/front/src/navigation/ProfileStack.js b/front/src/navigation/ProfileStack.js
index 1504db6..e00ed6c 100644
--- a/front/src/navigation/ProfileStack.js
+++ b/front/src/navigation/ProfileStack.js
@@ -5,6 +5,7 @@ import EditProfileScreen from '../screens/Profile/EditProfileScreen';
import RankRoadmapScreen from '../screens/Profile/RankRoadmapScreen';
import TrophyRoomScreen from '../screens/Profile/TrophyRoomScreen';
import SettingsScreen from '../screens/Profile/SettingsScreen';
+import InventoryScreen from '../screens/Profile/InventoryScreen';
import { Colors } from '../constants/theme';
const Stack = createStackNavigator();
@@ -57,6 +58,11 @@ export default function ProfileStack() {
component={SettingsScreen}
options={{ title: 'Réglages' }}
/>
+
);
}
diff --git a/front/src/navigation/SocialStack.js b/front/src/navigation/SocialStack.js
new file mode 100644
index 0000000..563d286
--- /dev/null
+++ b/front/src/navigation/SocialStack.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { createStackNavigator } from '@react-navigation/stack';
+import SocialScreen from '../screens/Social/SocialScreen';
+import FriendProfileScreen from '../screens/Social/FriendProfileScreen';
+
+const Stack = createStackNavigator();
+
+// Les deux écrans gèrent leur propre header (fond abyss + chevron custom)
+export default function SocialStack() {
+ return (
+
+
+
+
+ );
+}
diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js
index 9319117..de3746f 100644
--- a/front/src/navigation/index.js
+++ b/front/src/navigation/index.js
@@ -8,6 +8,7 @@ import { ActivityIndicator, View } from 'react-native';
import AuthStack from './AuthStack';
import BottomTabs from './BottomTabs';
+import { navigationRef } from './navigationRef';
import { Colors } from '../constants/theme';
import { useAuth } from '../context/AuthContext';
@@ -17,15 +18,44 @@ import { CustomExercisesProvider } from '../context/CustomExercisesContext';
import { QuestProvider } from '../context/QuestContext';
import { UserProvider } from '../context/UserContext';
import { TutorialProvider } from '../context/TutorialContext';
-import { setupNotificationChannels } from '../services/notificationService';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { setupNotificationChannels, ensureDailyRemindersScheduled, getExpoPushToken } from '../services';
+import { registerPushToken } from '../services';
+import BirthdayCelebration from '../components/profile/BirthdayCelebration';
+import LevelUpCelebration from '../components/profile/LevelUpCelebration';
+import ActivityFeedModal from '../components/social/ActivityFeedModal';
+import WeightReminderCheck from '../components/stats/WeightReminderCheck';
+import LobbyInviteCheck from '../components/workouts/LobbyInviteCheck';
+
+const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1';
export default function AppNavigator() {
const { userToken, isLoading } = useAuth();
useEffect(() => {
setupNotificationChannels();
+ (async () => {
+ try {
+ const enabled = await AsyncStorage.getItem(NOTIF_ENABLED_KEY);
+ if (enabled === 'true') await ensureDailyRemindersScheduled();
+ } catch (_) {}
+ })();
}, []);
+ // Enregistre le token Expo Push auprès du backend une fois connecté — c'est
+ // ce qui permet aux notifications d'un AUTRE appareil (Secouer, réactions
+ // du flux d'activité) d'atteindre réellement celui-ci. Best-effort : un
+ // échec (permission refusée, web, hors ligne) ne bloque jamais l'app.
+ useEffect(() => {
+ if (!userToken) return;
+ (async () => {
+ const token = await getExpoPushToken();
+ if (token) {
+ try { await registerPushToken(token); } catch (_) {}
+ }
+ })();
+ }, [userToken]);
+
if (isLoading) {
return (
+
- {userToken === null ? : }
+ {userToken === null ? : (
+ <>
+
+
+
+
+
+
+ >
+ )}
diff --git a/front/src/navigation/navigationRef.js b/front/src/navigation/navigationRef.js
new file mode 100644
index 0000000..b1c65f0
--- /dev/null
+++ b/front/src/navigation/navigationRef.js
@@ -0,0 +1,12 @@
+import { createNavigationContainerRef } from '@react-navigation/native';
+
+// Réf globale du conteneur de navigation — permet de naviguer depuis des
+// composants montés hors de toute pile (ex: LobbyInviteCheck, qui réagit à
+// une notification push reçue au premier plan et n'a pas de prop `navigation`).
+export const navigationRef = createNavigationContainerRef();
+
+export function navigate(name, params) {
+ if (navigationRef.isReady()) {
+ navigationRef.navigate(name, params);
+ }
+}
diff --git a/front/src/screens/Auth/AuthScreen.js b/front/src/screens/Auth/AuthScreen.js
deleted file mode 100644
index e4a3609..0000000
--- a/front/src/screens/Auth/AuthScreen.js
+++ /dev/null
@@ -1,396 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import {
- View, Text, StyleSheet, TouchableOpacity, Image,
- KeyboardAvoidingView, Platform, ActivityIndicator,
- Dimensions, Animated, Easing, ScrollView, StatusBar,
-} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { Ionicons } from '@expo/vector-icons';
-
-import { Colors } from '../../constants/theme';
-import AuthInput from '../../components/inputs/AuthInput';
-import { login, register } from '../../services/auth.service';
-import { useAuth } from '../../context/AuthContext';
-
-// Imports statiques des assets (chemin : src/screens/Auth/ → ../../../assets/)
-const LOGO_ORANGE = require('../../../assets/logo-orange.png');
-const LOGO_ICON_ORANGE = require('../../../assets/minimalist-logo-orange.png');
-
-const { width: _rawW } = Dimensions.get('window');
-const W = Platform.OS === 'web' ? Math.min(430, _rawW) : _rawW;
-const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
-function FadeLoader() {
- const opacity = useRef(new Animated.Value(0)).current;
- useEffect(() => {
- Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }).start();
- }, []);
- return (
-
-
-
- );
-}
-
-export default function AuthScreen() {
- const { signIn } = useAuth();
- const slideAnim = useRef(new Animated.Value(0)).current;
-
- // ── Login state ──────────────────────────────────────────
- const [loginEmail, setLoginEmail] = useState('');
- const [loginPassword, setLoginPassword] = useState('');
- const [loginLoading, setLoginLoading] = useState(false);
- const [loginShowPwd, setLoginShowPwd] = useState(false);
- const [loginEmailErr, setLoginEmailErr] = useState('');
- const [loginGlobalErr, setLoginGlobalErr] = useState('');
-
- // ── Register state ───────────────────────────────────────
- const [regName, setRegName] = useState('');
- const [regEmail, setRegEmail] = useState('');
- const [regPassword, setRegPassword] = useState('');
- const [regLoading, setRegLoading] = useState(false);
- const [regShowPwd, setRegShowPwd] = useState(false);
- const [regNameErr, setRegNameErr] = useState('');
- const [regEmailErr, setRegEmailErr] = useState('');
- const [regPwdErr, setRegPwdErr] = useState('');
- const [regGlobalErr, setRegGlobalErr] = useState('');
-
- // ── Slide ────────────────────────────────────────────────
- const slideTo = (panel) => {
- Animated.timing(slideAnim, {
- toValue: panel === 'login' ? 0 : -W,
- duration: 400,
- easing: Easing.out(Easing.exp),
- useNativeDriver: true,
- }).start();
- };
-
- // ── Validators ───────────────────────────────────────────
- const validateLoginEmail = (val = loginEmail) => {
- if (!val) { setLoginEmailErr('Email requis'); return false; }
- if (!EMAIL_RE.test(val)) { setLoginEmailErr('Email invalide'); return false; }
- setLoginEmailErr(''); return true;
- };
-
- const validateRegEmail = (val = regEmail) => {
- if (!val) { setRegEmailErr('Email requis'); return false; }
- if (!EMAIL_RE.test(val)) { setRegEmailErr('Email invalide'); return false; }
- setRegEmailErr(''); return true;
- };
-
- // ── Handlers ─────────────────────────────────────────────
- const handleLogin = async () => {
- if (!validateLoginEmail() || !loginPassword) return;
- try {
- setLoginLoading(true);
- setLoginGlobalErr('');
- const res = await login(loginEmail, loginPassword);
- if (res?.token) await signIn(res.token);
- else throw new Error();
- } catch {
- setLoginGlobalErr('Email ou mot de passe incorrect.');
- } finally {
- setLoginLoading(false);
- }
- };
-
- const handleRegister = async () => {
- let ok = true;
- if (!regName) { setRegNameErr('Nom requis'); ok = false; } else setRegNameErr('');
- if (!validateRegEmail()) ok = false;
- if (regPassword.length < 6) { setRegPwdErr('6 caractères minimum'); ok = false; } else setRegPwdErr('');
- if (!ok) return;
- try {
- setRegLoading(true);
- setRegGlobalErr('');
- const res = await register({ name: regName, email: regEmail, password: regPassword });
- if (res?.token) await signIn(res.token);
- else slideTo('login');
- } catch {
- setRegGlobalErr("Erreur lors de l'inscription. Email déjà utilisé ?");
- } finally {
- setRegLoading(false);
- }
- };
-
- return (
-
-
-
-
-
-
-
- {/* ══════════════════════════════════════════════════
- PANEL LOGIN
- ══════════════════════════════════════════════════ */}
-
-
-
-
- Bienvenue
- Connectez-vous pour continuer
-
-
- { setLoginEmail(v); if (loginEmailErr) validateLoginEmail(v); }}
- onBlur={() => validateLoginEmail()}
- error={loginEmailErr}
- keyboardType="email-address"
- autoCapitalize="none"
- autoCorrect={false}
- />
-
-
- {loginGlobalErr ? {loginGlobalErr} : null}
-
-
- {loginLoading ? : Se connecter}
-
-
-
- Pas encore de compte ?
- slideTo('register')}>
- S'inscrire
-
-
-
-
- {/* ══════════════════════════════════════════════════
- PANEL REGISTER
- ══════════════════════════════════════════════════ */}
-
- slideTo('login')}>
-
-
-
-
-
-
- Créer un compte
- Rejoignez la communauté Athly
-
-
- { setRegName(v); if (regNameErr) setRegNameErr(''); }}
- error={regNameErr}
- />
- { setRegEmail(v); if (regEmailErr) validateRegEmail(v); }}
- onBlur={() => validateRegEmail()}
- error={regEmailErr}
- keyboardType="email-address"
- autoCapitalize="none"
- autoCorrect={false}
- />
- {
- setRegPassword(v);
- if (regPwdErr) setRegPwdErr(v.length >= 6 ? '' : '6 caractères minimum');
- }}
- isPassword
- secureTextEntry={!regShowPwd}
- showPassword={regShowPwd}
- setShowPassword={setRegShowPwd}
- error={regPwdErr}
- />
-
- {regGlobalErr ? {regGlobalErr} : null}
-
-
- {regLoading ? : Créer mon compte}
-
-
-
- Déjà inscrit ?
- slideTo('login')}>
- Se connecter
-
-
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- safeArea: {
- flex: 1,
- backgroundColor: Colors.backgroundDeep,
- },
- kav: {
- flex: 1,
- backgroundColor: Colors.backgroundDeep,
- },
- clipper: {
- flex: 1,
- overflow: 'hidden',
- backgroundColor: Colors.backgroundDeep,
- },
- slider: {
- flex: 1,
- flexDirection: 'row',
- width: W * 2,
- },
- panel: {
- width: W,
- backgroundColor: Colors.backgroundDeep,
- },
- panelContent: {
- paddingHorizontal: 28,
- paddingTop: 20,
- paddingBottom: 56,
- },
-
- // ── Logo Login (image brute, dimensions explicites) ──────
- logoImg: {
- width: 180,
- height: 120,
- alignSelf: 'center',
- marginBottom: 30,
- marginTop: 4,
- },
- // ── Logo Register (icône discrète) ───────────────────────
- logoSmall: {
- width: 26,
- height: 26,
- alignSelf: 'flex-start',
- marginBottom: 20,
- opacity: 0.6,
- },
-
- // ── Typographie ──────────────────────────────────────────
- titleBlock: {
- marginBottom: 32,
- },
- brand: {
- color: Colors.textPrimary,
- fontSize: 30,
- fontWeight: '700',
- letterSpacing: -0.5,
- marginBottom: 6,
- },
- tagline: {
- color: Colors.textMuted,
- fontSize: 14,
- letterSpacing: 0.2,
- },
-
- // ── Erreur globale ───────────────────────────────────────
- globalError: {
- color: Colors.error,
- fontSize: 13,
- textAlign: 'center',
- marginTop: 8,
- marginBottom: 4,
- },
-
- // ── Bouton principal ─────────────────────────────────────
- primaryBtn: {
- backgroundColor: Colors.primary,
- height: 56,
- borderRadius: 14,
- justifyContent: 'center',
- alignItems: 'center',
- marginTop: 24,
- shadowColor: Colors.primary,
- shadowOffset: { width: 0, height: 8 },
- shadowOpacity: 0.4,
- shadowRadius: 16,
- elevation: 8,
- },
- btnText: {
- color: '#fff',
- fontSize: 16,
- fontWeight: '700',
- letterSpacing: 0.4,
- },
-
- // ── Lien de bascule ──────────────────────────────────────
- switchRow: {
- flexDirection: 'row',
- justifyContent: 'center',
- marginTop: 36,
- },
- switchLabel: {
- color: Colors.textMuted,
- fontSize: 14,
- },
- linkBold: {
- color: Colors.primary,
- fontWeight: '700',
- fontSize: 14,
- },
-
- // ── Retour ───────────────────────────────────────────────
- backBtn: {
- width: 36,
- height: 36,
- justifyContent: 'center',
- marginBottom: 8,
- },
-});
diff --git a/front/src/screens/Auth/EmailVerificationScreen.js b/front/src/screens/Auth/EmailVerificationScreen.js
index 24b85cf..8c08329 100644
--- a/front/src/screens/Auth/EmailVerificationScreen.js
+++ b/front/src/screens/Auth/EmailVerificationScreen.js
@@ -8,8 +8,8 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../../constants/theme';
-import NotificationBanner from '../../components/common/NotificationBanner';
-import { verifyEmail, resendVerificationEmail } from '../../services/auth.service';
+import { NotificationBanner } from '../../components/common';
+import { verifyEmail, resendVerificationEmail } from '../../services';
import { useAuth } from '../../context/AuthContext';
const CODE_LENGTH = 6;
diff --git a/front/src/screens/Auth/ForgotPasswordScreen.js b/front/src/screens/Auth/ForgotPasswordScreen.js
index e6c603d..bd3d90a 100644
--- a/front/src/screens/Auth/ForgotPasswordScreen.js
+++ b/front/src/screens/Auth/ForgotPasswordScreen.js
@@ -9,8 +9,8 @@ import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../../constants/theme';
import AuthInput from '../../components/inputs/AuthInput';
-import NotificationBanner from '../../components/common/NotificationBanner';
-import { forgotPassword, resetPassword } from '../../services/auth.service';
+import { NotificationBanner } from '../../components/common';
+import { forgotPassword, resetPassword } from '../../services';
import { useToast } from '../../context/ToastContext';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -33,7 +33,7 @@ function getStrength(pwd) {
function StrengthBar({ password }) {
const score = getStrength(password);
if (!password) return null;
- const color = score === 3 ? '#44FF88' : score === 2 ? '#FFA500' : '#FF4D4D';
+ const color = score === 3 ? Colors.success : score === 2 ? '#FFA500' : Colors.error;
const label = score === 3 ? 'Fort' : score === 2 ? 'Moyen' : 'Faible';
return (
@@ -134,7 +134,7 @@ function WeakPasswordModal({ visible, onImprove, onSave, loading }) {
-
+
Mot de passe simple
@@ -182,7 +182,7 @@ const wm = StyleSheet.create({
},
card: {
width: '100%',
- backgroundColor: '#13131C',
+ backgroundColor: Colors.bgDeep2,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.25)',
@@ -388,7 +388,7 @@ export default function ForgotPasswordScreen({ navigation }) {
{/* ══════════════════════════════════════════════════
- ÉTAPE 1 — Saisie de l'email
+ ÉTAPE 1 - Saisie de l'email
══════════════════════════════════════════════════ */}
{step === 1 && (
<>
@@ -438,7 +438,7 @@ export default function ForgotPasswordScreen({ navigation }) {
)}
{/* ══════════════════════════════════════════════════
- ÉTAPE 2 — Code + Nouveau mot de passe
+ ÉTAPE 2 - Code + Nouveau mot de passe
══════════════════════════════════════════════════ */}
{step === 2 && (
<>
diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js
index 4aecc0d..e7b9b9f 100644
--- a/front/src/screens/Auth/LoginScreen.js
+++ b/front/src/screens/Auth/LoginScreen.js
@@ -9,9 +9,10 @@ import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../../constants/theme';
import AuthInput from '../../components/inputs/AuthInput';
-import NotificationBanner from '../../components/common/NotificationBanner';
-import { login } from '../../services/auth.service';
+import { NotificationBanner } from '../../components/common';
+import { login, googleLogin } from '../../services';
import { useAuth } from '../../context/AuthContext';
+import { useGoogleAuth } from '../../hooks';
const LOGO_ORANGE = require('../../../assets/logo-orange.png');
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -30,16 +31,53 @@ function FadeLoader() {
export default function LoginScreen({ navigation }) {
const { signIn } = useAuth();
+ const { isConfigured: googleConfigured, request: googleRequest, idToken, promptAsync } = useGoogleAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
+ const [googleLoading, setGoogleLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [emailErr, setEmailErr] = useState('');
const [globalErr, setGlobalErr] = useState('');
const [errType, setErrType] = useState('error');
+ // Dès que promptAsync() résout avec succès, useGoogleAuth expose l'idToken —
+ // on le transmet immédiatement au backend pour vérification et connexion.
+ useEffect(() => {
+ if (!idToken) return;
+ (async () => {
+ try {
+ setGoogleLoading(true);
+ setGlobalErr('');
+ const res = await googleLogin(idToken);
+ if (res?.token) {
+ await signIn(res.token, rememberMe);
+ }
+ } catch (error) {
+ const status = error?.status;
+ if (status >= 500) {
+ setErrType('info');
+ setGlobalErr('Une erreur est survenue, notre équipe est sur le coup.');
+ } else {
+ setErrType('error');
+ setGlobalErr('Connexion Google impossible. Réessaie.');
+ }
+ } finally {
+ setGoogleLoading(false);
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [idToken]);
+
+ const handleGoogleLogin = async () => {
+ try { await promptAsync(); } catch (_) {
+ setErrType('error');
+ setGlobalErr('Connexion Google impossible. Réessaie.');
+ }
+ };
+
const validateEmail = (val = email) => {
if (!val) { setEmailErr('Email requis'); return false; }
if (!EMAIL_RE.test(val)) { setEmailErr('Email invalide'); return false; }
@@ -163,6 +201,24 @@ export default function LoginScreen({ navigation }) {
+ {googleConfigured && (
+
+ {googleLoading
+ ?
+ : (
+ <>
+
+ Continuer avec Google
+ >
+ )}
+
+ )}
+
Pas encore de compte ?
navigation.navigate('Register')}>
@@ -225,6 +281,15 @@ const s = StyleSheet.create({
dividerLine: { flex: 1, height: 1, backgroundColor: Colors.separator },
dividerText: { color: Colors.textMuted, marginHorizontal: 12, fontSize: 12 },
+ googleBtn: {
+ flexDirection: 'row', height: 56, borderRadius: 14,
+ justifyContent: 'center', alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.14)',
+ marginBottom: 20,
+ },
+ googleBtnText: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' },
+
switchRow: { flexDirection: 'row', justifyContent: 'center' },
switchLabel: { color: Colors.textMuted, fontSize: 14 },
linkBold: { color: Colors.primary, fontWeight: '700', fontSize: 14 },
diff --git a/front/src/screens/Auth/RegisterScreen.js b/front/src/screens/Auth/RegisterScreen.js
index 89921e9..d9170a7 100644
--- a/front/src/screens/Auth/RegisterScreen.js
+++ b/front/src/screens/Auth/RegisterScreen.js
@@ -9,8 +9,9 @@ import { Ionicons } from '@expo/vector-icons';
import { Colors } from '../../constants/theme';
import AuthInput from '../../components/inputs/AuthInput';
-import NotificationBanner from '../../components/common/NotificationBanner';
-import { register } from '../../services/auth.service';
+import { NotificationBanner } from '../../components/common';
+import { register } from '../../services';
+import { haptics } from '../../services';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const HAS_UPPER = /[A-Z]/;
@@ -31,7 +32,7 @@ function getStrength(pwd) {
function StrengthBar({ password }) {
const score = getStrength(password);
if (!password) return null;
- const color = score === 3 ? '#44FF88' : score === 2 ? '#FFA500' : '#FF4D4D';
+ const color = score === 3 ? Colors.success : score === 2 ? '#FFA500' : Colors.error;
const label = score === 3 ? 'Fort' : score === 2 ? 'Moyen' : 'Faible';
return (
@@ -75,7 +76,7 @@ function WeakPasswordModal({ visible, onImprove, onCreate, loading }) {
{/* Icône */}
-
+
Mot de passe simple
@@ -123,7 +124,7 @@ const wm = StyleSheet.create({
},
card: {
width: '100%',
- backgroundColor: '#13131C',
+ backgroundColor: Colors.bgDeep2,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.25)',
@@ -200,11 +201,91 @@ const wm = StyleSheet.create({
},
});
+// ─── Popup "Pseudo non autorisé" ──────────────────────────────────────────────
+// Ton volontairement plus ferme que WeakPasswordModal (qui reste un conseil) :
+// ici on avertit explicitement que le pseudo a été refusé par la modération
+// automatique et qu'une insistance répétée peut mener à des restrictions de
+// compte — dissuasif sans bloquer techniquement la nouvelle tentative.
+
+function PseudoRejectedModal({ visible, onClose }) {
+ return (
+
+
+
+
+
+
+
+ Pseudo non autorisé
+
+ Ce pseudo a été refusé car il contient un terme injurieux ou inapproprié.
+ Athly est une communauté respectueuse - merci d'en choisir un autre.
+
+
+ Toute tentative répétée avec un pseudo de ce type pourra entraîner des
+ restrictions sur ton compte.
+
+
+
+ Choisir un autre pseudo
+
+
+
+
+ );
+}
+
+const pm = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.82)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.bgDeep2,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: 'rgba(239,68,68,0.35)',
+ padding: 28,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 16 },
+ shadowOpacity: 0.6,
+ shadowRadius: 32,
+ elevation: 20,
+ },
+ iconWrap: {
+ width: 60, height: 60, borderRadius: 18,
+ backgroundColor: 'rgba(239,68,68,0.12)',
+ borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)',
+ justifyContent: 'center', alignItems: 'center', marginBottom: 18,
+ },
+ title: { color: Colors.textPrimary, fontSize: 19, fontWeight: '800', letterSpacing: -0.3, marginBottom: 12, textAlign: 'center' },
+ body: { color: Colors.textSecondary, fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 14 },
+ warning: { color: Colors.destructive, fontSize: 12.5, fontWeight: '700', lineHeight: 18, textAlign: 'center', marginBottom: 24 },
+ closeBtn: {
+ width: '100%', height: 50, borderRadius: 13,
+ backgroundColor: Colors.destructive, justifyContent: 'center', alignItems: 'center',
+ shadowColor: Colors.destructive, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6,
+ },
+ closeTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
+});
+
// ─── Écran principal ──────────────────────────────────────────────────────────
export default function RegisterScreen({ navigation }) {
const [pseudo, setPseudo] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
+ const [referralCode, setReferralCode] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
@@ -219,6 +300,7 @@ export default function RegisterScreen({ navigation }) {
const [errType, setErrType] = useState('error');
const [weakModalVisible, setWeakModalVisible] = useState(false);
+ const [pseudoRejectedVisible, setPseudoRejectedVisible] = useState(false);
// ── Règles allégées : seul le minimum absolu bloque le bouton ────────────────
const isPwdMinimal = password.length >= MIN_PWD;
@@ -259,7 +341,7 @@ export default function RegisterScreen({ navigation }) {
try {
setLoading(true);
setGlobalErr('');
- await register({ pseudo, email, password });
+ await register({ pseudo, email, password, referralCode: referralCode.trim() });
navigation.navigate('EmailVerification', { email });
} catch (error) {
const status = error?.status;
@@ -270,9 +352,16 @@ export default function RegisterScreen({ navigation }) {
} else if (status >= 500) {
setErrType('info');
setGlobalErr('Une erreur est survenue, notre équipe est sur le coup.');
+ } else if (msg.toLowerCase().includes('parrainage')) {
+ setErrType('error');
+ setGlobalErr('Code de parrainage invalide. Vérifie-le ou laisse le champ vide.');
} else if (msg.toLowerCase().includes('email')) {
setErrType('error');
setGlobalErr('Cet email est déjà utilisé.');
+ } else if (msg.toLowerCase().includes('pseudo')) {
+ haptics.error();
+ setPseudoErr('Pseudo non autorisé');
+ setPseudoRejectedVisible(true);
} else {
setErrType('error');
setGlobalErr("Erreur lors de l'inscription. Réessayez.");
@@ -280,7 +369,7 @@ export default function RegisterScreen({ navigation }) {
} finally {
setLoading(false);
}
- }, [pseudo, email, password, navigation]);
+ }, [pseudo, email, password, referralCode, navigation]);
// ── Soumission : validation + vérification de force ───────────────────────
const handleSubmit = useCallback(() => {
@@ -380,6 +469,16 @@ export default function RegisterScreen({ navigation }) {
error={confirmErr}
/>
+ setReferralCode(v.toUpperCase())}
+ autoCapitalize="characters"
+ autoCorrect={false}
+ />
+
{globalErr ? : null}
+
+ { setPseudoRejectedVisible(false); setPseudo(''); }}
+ />
);
}
diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js
index ba7b77b..35ce1b1 100644
--- a/front/src/screens/Home/HomeScreen.js
+++ b/front/src/screens/Home/HomeScreen.js
@@ -13,12 +13,13 @@ 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,
aggregateGlobal,
xpToLevel,
-} from '../../services/stats.service';
+} from '../../services';
import { findMuscleGroup } from '../../constants/exerciseFilters';
import {
TEMPLATES,
@@ -34,7 +35,7 @@ import DailyQuestsCard from '../../components/home/DailyQuestsCard';
import RecoveryRitualsCard from '../../components/home/RecoveryRitualsCard';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
-import { useDevSettings } from '../../hooks/useDevSettings';
+import { useDevSettings } from '../../hooks';
import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats';
// Écran d'accueil. Bascule entre EmptyHomeState (compte vierge) et état actif
@@ -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 ────
@@ -178,8 +189,8 @@ export default function HomeScreen({ navigation }) {
{isTutorialDashboard && (
-
- Données de démonstration — disparaîtront à la fin du chapitre
+
+ Données de démonstration - disparaîtront à la fin du chapitre
)}
o.toLowerCase() === val.toLowerCase()) || val;
+}
+
export default function EditProfileScreen({ navigation }) {
const { user, refetch: refetchUser } = useUser();
const { showToast } = useToast();
-
const [formData, setFormData] = useState({
name: user?.name || '',
bio: user?.bio || '',
@@ -34,7 +43,7 @@ export default function EditProfileScreen({ navigation }) {
taille: user?.taille?.toString() || '',
poidsCible: user?.poidsCible?.toString() || '',
sexe: user?.sexe || '',
- objectif: user?.objectif || '',
+ objectif: normalizeObjectif(user?.objectif),
niveauSportif: user?.niveauSportif || 'Débutant',
rythme: user?.rythme?.toString() || '',
});
@@ -49,7 +58,7 @@ export default function EditProfileScreen({ navigation }) {
taille: user.taille?.toString() || '',
poidsCible: user.poidsCible?.toString() || '',
sexe: user.sexe || 'H',
- objectif: user.objectif || 'Entretien',
+ objectif: normalizeObjectif(user.objectif) || 'Entretien',
niveauSportif: user.niveauSportif || 'Débutant',
rythme: user.rythme?.toString() || '',
});
@@ -78,19 +87,28 @@ export default function EditProfileScreen({ navigation }) {
setErrors({});
setLoading(true);
- // bio excluded from payload — backend schema doesn't allow it
- const { bio: _bio, ...restFormData } = formData;
+ // On ne transmet que les champs renseignés : poids/taille/rythme n'ont pas
+ // .allow(null) dans le schema Joi → les envoyer à null déclenche une 400.
+ // objectif : le backend attend les minuscules ("prise de masse"), le front
+ // affiche des majuscules → on normalise avant envoi.
+ const parsedPoids = parseFloat(formData.poids?.replace(',', '.')) || null;
+ const parsedTaille = parseInt(formData.taille, 10) || null;
+ const parsedPoidsCible = parseFloat(formData.poidsCible?.replace(',', '.')) || null;
+ const parsedRythme = parseInt(formData.rythme, 10) || null;
+
const payload = {
- ...restFormData,
- name: nameVal,
- poids: parseFloat(formData.poids?.replace(',', '.')) || null,
- taille: parseInt(formData.taille) || null,
- poidsCible: parseFloat(formData.poidsCible?.replace(',', '.')) || null,
- rythme: parseInt(formData.rythme) || null,
+ name: nameVal,
+ ...(formData.sexe && { sexe: formData.sexe }),
+ ...(formData.niveauSportif && { niveauSportif: formData.niveauSportif }),
+ ...(formData.objectif && { objectif: formData.objectif.toLowerCase() }),
+ ...(parsedPoids !== null && { poids: parsedPoids }),
+ ...(parsedTaille !== null && { taille: parsedTaille }),
+ ...(parsedPoidsCible !== null && { poidsCible: parsedPoidsCible }),
+ ...(parsedRythme !== null && { rythme: parsedRythme }),
};
try {
- const res = await API.put('/users/me', payload);
- if (res.data.success) {
+ const res = await updateMyProfile(payload);
+ if (res.success) {
refetchUser(); // met à jour le UserContext → ProfileScreen + SettingsScreen sync instantané
showToast('Profil mis à jour avec succès !', 'success');
navigation.goBack();
@@ -122,6 +140,22 @@ export default function EditProfileScreen({ navigation }) {
handleUpdateRef.current = handleUpdate;
+ // Anti-triche : setBirthdate est un endpoint dédié (pas /users/me), verrouillé
+ // côté backend dès la première saisie. On re-throw en cas d'échec pour que
+ // BirthdatePicker garde sa modale ouverte et permette un nouvel essai.
+ const handleBirthdateConfirm = useCallback(async (dateObj) => {
+ try {
+ await setBirthdate(dateObj.toISOString());
+ await refetchUser();
+ showToast('Date de naissance enregistrée. Ton coffre est dans ton inventaire !', 'success');
+ } catch (error) {
+ if (error.isSessionExpired) throw error;
+ const msg = error.data?.message || error.message || 'Erreur réseau. Réessaie dans un instant.';
+ showToast(msg, 'error');
+ throw error;
+ }
+ }, [refetchUser, showToast]);
+
// Configure native header with "Sauver" button — re-runs when loading changes
useLayoutEffect(() => {
navigation.setOptions({
@@ -194,7 +228,7 @@ export default function EditProfileScreen({ navigation }) {
style={styles.inlineInput}
value={formData.poids}
onChangeText={(v) => set('poids', v)}
- placeholder="—"
+ placeholder="-"
placeholderTextColor={Colors.textMuted}
selectionColor={Colors.primary}
keyboardType="decimal-pad"
@@ -205,23 +239,29 @@ export default function EditProfileScreen({ navigation }) {
style={styles.inlineInput}
value={formData.poidsCible}
onChangeText={(v) => set('poidsCible', v)}
- placeholder="—"
+ placeholder="-"
placeholderTextColor={Colors.textMuted}
selectionColor={Colors.primary}
keyboardType="decimal-pad"
/>
-
+
set('taille', v)}
- placeholder="—"
+ placeholder="-"
placeholderTextColor={Colors.textMuted}
selectionColor={Colors.primary}
keyboardType="number-pad"
/>
+
+
+
{/* ═══ PROGRAMME ═════════════════════════════════════════════════════ */}
@@ -311,7 +351,7 @@ const GRP_BORDER = 'rgba(255,255,255,0.09)';
const SEP = 'rgba(255,255,255,0.07)';
const styles = StyleSheet.create({
- root: { flex: 1, backgroundColor: '#080910' },
+ root: { flex: 1, backgroundColor: Colors.bgAbyss },
scroll: { flex: 1 },
scrollContent: { paddingHorizontal: 16, paddingTop: 8 },
diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js
new file mode 100644
index 0000000..73a2a3f
--- /dev/null
+++ b/front/src/screens/Profile/InventoryScreen.js
@@ -0,0 +1,442 @@
+import React, { useState, useCallback, useRef, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, ScrollView, TouchableOpacity,
+ StatusBar, ActivityIndicator, Animated,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect } from '@react-navigation/native';
+import { Colors } from '../../constants/theme';
+import { useUser } from '../../context/UserContext';
+import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
+import { useToast } from '../../context/ToastContext';
+import { openChest, useItem, claimUniqueItem, ITEM_CATALOG, RARITY_META } from '../../services';
+import { xpForLevel, xpToLevel } from '../../services';
+import { useAvatarFrame } from '../../hooks';
+import { useDevSettings } from '../../hooks';
+import ChestOpeningModal from '../../components/inventory/ChestOpeningModal';
+import { haptics } from '../../services';
+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'];
+
+// Auto-équipement du cosmétique Unique juste après sa réclamation — même
+// logique que l'équipement manuel (useAvatarFrame persiste en local +
+// synchronise le backend ; setProfileThemeId reste une préférence locale).
+const CLAIM_EQUIP_ACTION = {
+ PROFILE_FRAME_BLOOD_BOND: (ctx) => ctx.selectShape('dragonfang'),
+ FRAME_COLOR_BLOOD_SANG: (ctx) => ctx.selectColor('bloodsang'),
+ THEME_UNLOCK_BLOOD_SANG: (ctx) => ctx.setProfileThemeId('blood_sang'),
+};
+
+// ─── InventoryScreen ──────────────────────────────────────────────────────────
+// Inventaire RPG (Brique II) : coffres à ouvrir, consommables par rareté,
+// cosmétiques Uniques. Chaque carte entre en scène en cascade (stagger).
+
+export default function InventoryScreen({ navigation }) {
+ const { user, refetch } = useUser();
+ const { addBonusXp, totalXP } = useWorkoutLogs();
+ const { showToast } = useToast();
+ const { selectShape, selectColor } = useAvatarFrame();
+ const { setProfileThemeId } = useDevSettings();
+
+ 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, remeasure: rChest } = useTutorialTarget('inventory_chest');
+
+ useFocusEffect(useCallback(() => { refetch(); }, [refetch]));
+
+ useFocusEffect(
+ useCallback(() => {
+ if (pendingChapterId !== 'inventory') return;
+ // ProfileStack (@react-navigation/stack) glisse cet écran depuis la
+ // droite : mesurer la cible pendant la transition capture sa position
+ // mi-glissement → spotlight décalé à droite, hors-cadre. On attend la
+ // fin RÉELLE de la transition (transitionEnd) avant de re-mesurer et de
+ // démarrer le chapitre ; le setTimeout reste un filet de sécurité si
+ // l'event ne se déclenche pas (écran déjà stable, pas de transition).
+ let started = false;
+ const launch = () => {
+ if (started) return;
+ started = true;
+ rChest();
+ setTimeout(() => startChapter('inventory'), 50);
+ };
+ const unsub = navigation.addListener('transitionEnd', launch);
+ const t = setTimeout(launch, 500);
+ return () => { unsub(); clearTimeout(t); };
+ }, [pendingChapterId, startChapter, navigation, rChest]),
+ );
+
+ const inventory = user?.inventory ?? [];
+ const chestEntry = inventory.find((i) => i.itemType === 'CHEST_KEY');
+ const chestCount = chestEntry?.quantity ?? 0;
+ const level = user?.level ?? 1;
+ const chestLocked = level < MIN_LEVEL_FOR_CHEST;
+
+ // Items hors coffres, groupés par rareté (ordre : unique → commun)
+ const items = inventory
+ .filter((i) => i.itemType !== 'CHEST_KEY' && i.quantity > 0)
+ .sort((a, b) => RARITY_ORDER.indexOf(a.rarity) - RARITY_ORDER.indexOf(b.rarity));
+
+ const handleOpenChest = async () => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ const res = await openChest();
+ if (res.success) {
+ // Ouverture de coffre : moment marquant → vibration lourde.
+ haptics.heavy();
+ // La modale joue l'animation (shake → burst → reveal)
+ setChestModal({ visible: true, drawnItem: res.drawnItem });
+ }
+ } catch (error) {
+ if (error.isSessionExpired) return;
+ showToast(error.data?.message || 'Impossible d\'ouvrir le coffre.', 'error');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleUseItem = async (itemType) => {
+ if (busy) return;
+ setBusy(true);
+ const beforeXp = user?.xp ?? 0;
+ const beforeLevel = user?.level ?? 1;
+ try {
+ const res = await useItem(itemType);
+ if (res.success) {
+ showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} utilisé !`, 'success');
+ refetch();
+
+ // Le Profil affiche un niveau/XP calculé localement (logs de séances),
+ // distinct du user.xp backend. On pousse un log de synchronisation pour
+ // que l'usage d'un objet se reflète immédiatement sur le Profil/Accueil.
+ const xpGained = (res.user?.xp ?? beforeXp) - beforeXp;
+ const itemLabel = `Objet : ${ITEM_CATALOG[itemType]?.name ?? itemType}`;
+ if (xpGained > 0) {
+ addBonusXp(itemLabel, xpGained);
+ } else if ((res.user?.level ?? beforeLevel) > beforeLevel) {
+ // LEVEL_COUPON : pas de gain d'XP direct, on pousse localement de quoi
+ // franchir le prochain palier pour que le niveau affiché suive.
+ const needed = xpForLevel(xpToLevel(totalXP).level + 1) - totalXP;
+ if (needed > 0) addBonusXp(itemLabel, needed);
+ }
+ }
+ } catch (error) {
+ if (error.isSessionExpired) return;
+ showToast(error.data?.message || 'Impossible d\'utiliser cet objet.', 'error');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ // Réclame un cosmétique Unique (cadre/couleur/thème) : consomme l'item côté
+ // backend, débloque définitivement le cosmétique, puis l'équipe/sélectionne
+ // aussitôt côté front pour un retour immédiat.
+ const handleClaimItem = async (itemType) => {
+ if (busy) return;
+ setBusy(true);
+ try {
+ const res = await claimUniqueItem(itemType);
+ if (res.success) {
+ showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} réclamé !`, 'success');
+ const equip = CLAIM_EQUIP_ACTION[itemType];
+ if (equip) await equip({ selectShape, selectColor, setProfileThemeId });
+ refetch();
+ }
+ } catch (error) {
+ if (error.isSessionExpired) return;
+ showToast(error.data?.message || 'Impossible de réclamer cet objet.', 'error');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const closeChestModal = () => {
+ setChestModal({ visible: false, drawnItem: null });
+ refetch();
+ };
+
+ return (
+
+
+
+ {/* ── Header ── */}
+
+ navigation.goBack()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
+
+
+ Inventaire
+
+
+
+
+
+ {/* ── 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
+ {items.length === 0 ? (
+
+
+
+ Ton sac est vide.{'\n'}Enchaîne les séances pour gagner des coffres !
+
+
+ ) : (
+ items.map((entry, index) => (
+ handleUseItem(entry.itemType)}
+ onClaim={() => handleClaimItem(entry.itemType)}
+ />
+ ))
+ )}
+
+
+
+
+
+
+ {activeChapterId === 'inventory' && (
+
+ )}
+
+ );
+}
+
+// ─── Carte coffre avec flottement continu ────────────────────────────────────
+
+function ChestCard({ count, locked, busy, onOpen }) {
+ const float = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ const loop = Animated.loop(
+ Animated.sequence([
+ Animated.timing(float, { toValue: -6, duration: 1400, useNativeDriver: true }),
+ Animated.timing(float, { toValue: 0, duration: 1400, useNativeDriver: true }),
+ ]),
+ );
+ loop.start();
+ return () => loop.stop();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const canOpen = !locked && count > 0 && !busy;
+
+ return (
+
+
+
+
+
+
+
+ {locked ? 'Coffres verrouillés' : `${count} coffre${count > 1 ? 's' : ''} disponible${count > 1 ? 's' : ''}`}
+
+
+ {locked
+ ? `Atteins le niveau ${MIN_LEVEL_FOR_CHEST} (Rang Initié) pour les débloquer.`
+ : 'Cumule 2 h de séance pour gagner un coffre.'}
+
+
+
+
+ {busy
+ ?
+ : Ouvrir}
+
+
+ );
+}
+
+// ─── Carte item avec entrée en cascade ───────────────────────────────────────
+
+function StaggeredItemCard({ entry, index, busy, onUse, onClaim }) {
+ const slide = useRef(new Animated.Value(24)).current;
+ const fade = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(slide, {
+ toValue: 0, duration: 320, delay: index * 70, useNativeDriver: true,
+ }),
+ Animated.timing(fade, {
+ toValue: 1, duration: 320, delay: index * 70, useNativeDriver: true,
+ }),
+ ]).start();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const meta = ITEM_CATALOG[entry.itemType] ?? {};
+ const rarity = RARITY_META[entry.rarity] ?? RARITY_META.common;
+
+ return (
+
+
+
+
+
+
+
+ {meta.name ?? entry.itemType}
+ {entry.quantity > 1 && ×{entry.quantity}}
+
+ {rarity.label}
+ {meta.description}
+
+
+ {meta.usable && (
+
+ Utiliser
+
+ )}
+
+ {meta.claimable && (
+
+ Réclamer
+
+ )}
+
+ );
+}
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: Colors.bgAbyss },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingTop: 54,
+ paddingBottom: 14,
+ },
+ headerTitle: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800', letterSpacing: 0.2 },
+ content: { paddingHorizontal: 16 },
+
+ sectionLabel: {
+ color: Colors.textMuted, fontSize: 11, fontWeight: '700',
+ letterSpacing: 0.8, marginTop: 26, marginBottom: 10, marginLeft: 4,
+ },
+
+ // ── Coffre ──
+ chestSpot: { marginTop: 8 },
+ chestCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(254,116,57,0.07)',
+ borderWidth: 1,
+ borderColor: 'rgba(254,116,57,0.30)',
+ borderRadius: 18,
+ padding: 18,
+ },
+ chestCardLocked: {
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ borderColor: 'rgba(255,255,255,0.09)',
+ },
+ chestIconWrap: {
+ width: 60, height: 60, borderRadius: 16,
+ backgroundColor: 'rgba(255,255,255,0.05)',
+ justifyContent: 'center', alignItems: 'center', marginRight: 14,
+ },
+ chestInfo: { flex: 1, marginRight: 10 },
+ chestTitle: { color: Colors.textPrimary, fontSize: 15, fontWeight: '800', marginBottom: 3 },
+ chestSub: { color: Colors.textSecondary, fontSize: 12, lineHeight: 17 },
+ openBtn: {
+ backgroundColor: Colors.primary,
+ borderRadius: 11,
+ paddingHorizontal: 18,
+ height: 40,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ openBtnDisabled: { opacity: 0.35 },
+ openBtnTxt: { color: '#fff', fontSize: 13, fontWeight: '800' },
+
+ // ── Items ──
+ itemCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1,
+ borderRadius: 16,
+ padding: 14,
+ marginBottom: 10,
+ },
+ itemIconBox: {
+ width: 52, height: 52, borderRadius: 14,
+ justifyContent: 'center', alignItems: 'center', marginRight: 12,
+ },
+ itemInfo: { flex: 1, marginRight: 10 },
+ itemNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ itemName: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' },
+ itemQty: { color: Colors.textSecondary, fontSize: 13, fontWeight: '800' },
+ itemRarity:{ fontSize: 11, fontWeight: '800', letterSpacing: 1, marginTop: 1, marginBottom: 3 },
+ itemDesc: { color: Colors.textMuted, fontSize: 11.5, lineHeight: 16 },
+ useBtn: {
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingHorizontal: 13,
+ height: 34,
+ justifyContent: 'center',
+ },
+ useBtnTxt: { fontSize: 12, fontWeight: '800' },
+
+ // ── Vide ──
+ emptyBox: { alignItems: 'center', paddingVertical: 40 },
+ emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' },
+});
diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js
index 3e22c51..01f4864 100644
--- a/front/src/screens/Profile/ProfileScreen.js
+++ b/front/src/screens/Profile/ProfileScreen.js
@@ -19,16 +19,20 @@ import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
import { useUser } from '../../context/UserContext';
import { Colors } from '../../constants/theme';
import {
- getPersonalRecords,
aggregateActivityHeatmap,
xpToLevel,
computeStreak,
getRank,
-} from '../../services/stats.service';
-import { MAJOR_EXERCISES } from '../../data/majorExercises';
-import { useAvatarFrame } from '../../hooks/useAvatarFrame';
-import { useDevSettings } from '../../hooks/useDevSettings';
-import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies';
+} from '../../services';
+import { resolveExerciseMeta } from '../../data/majorExercises';
+import { getMyRecords } from '../../services';
+import { getTitles } from '../../services';
+import { haptics } from '../../services';
+import { RARITY_META } from '../../services';
+import { syncLocalAchievements } from '../../services';
+import { useAvatarFrame } from '../../hooks';
+import { useDevSettings } from '../../hooks';
+import { useFeaturedTrophies } from '../../hooks';
import { getTheme } from '../../data/profileThemes';
import { evaluateTrophies, ULTIMATE_TROPHY } from '../../data/trophyCatalog';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
@@ -37,13 +41,16 @@ import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
import HeroLevelCard from '../../components/profile/HeroLevelCard';
import TrophyGrid from '../../components/profile/TrophyGrid';
import PersonalRecordsList from '../../components/profile/PersonalRecordsList';
+import RecordsShowcasePicker from '../../components/profile/RecordsShowcasePicker';
import ActivityHeatmap from '../../components/profile/ActivityHeatmap';
import EmberParticles from '../../components/profile/EmberParticles';
import StreakBadge from '../../components/profile/StreakBadge';
import BorderPicker from '../../components/profile/BorderPicker';
+import TitlePickerModal from '../../components/profile/TitlePickerModal';
-function buildBgGradient(isGod, isLegend, isElite) {
+function buildBgGradient(isGod, isLegend, isElite, isBlood) {
+ if (isBlood) return ['#0A0000', '#150202', Colors.bgAbyss, '#0A0000'];
if (isGod) return ['#0A0800', '#100D02', Colors.bgAbyss, '#08080E'];
if (isLegend) return [Colors.bgAbyss, '#0C0816', '#0D0A1A', Colors.bgAbyss];
if (isElite) return [Colors.bgAbyss, '#0A0A18', '#0D0D1C', Colors.bgAbyss];
@@ -57,9 +64,11 @@ export default function ProfileScreen({ navigation }) {
const { sessionLogs: logs, activityLogs, totalXP, loading: logsLoading } = useWorkoutLogs();
const { user, loading: profileLoading, refetch: refetchUser } = useUser();
const [borderPickerVisible, setBorderPickerVisible] = useState(false);
+ const [titlePickerVisible, setTitlePickerVisible] = useState(false);
const { shapeId, colorId, selectShape, selectColor } = useAvatarFrame();
const { profileThemeId, godMode, trophyOverrides, reload: reloadDevSettings } = useDevSettings();
const { featuredIds, toggleFeatured, reload: reloadFeatured } = useFeaturedTrophies();
+ const [equippedTitleMeta, setEquippedTitleMeta] = useState(null); // { label, rarity } | null
// ─── Tutorial ─────────────────────────────────────────────────────────────
const {
@@ -81,6 +90,21 @@ export default function ProfileScreen({ navigation }) {
}, [pendingChapterId, startChapter]),
);
+ // Résout le titre équipé (label + couleur de rareté) — rafraîchi à chaque
+ // retour sur l'écran et à chaque fermeture du sélecteur de titres (l'équipement
+ // a pu changer pendant que la popup était ouverte).
+ const loadEquippedTitle = useCallback(async () => {
+ try {
+ const res = await getTitles();
+ const equipped = res.titles.find((t) => t.id === res.equippedTitle);
+ setEquippedTitleMeta(equipped ? { label: equipped.label, rarity: equipped.rarity } : null);
+ } catch (_) {
+ // best-effort — l'absence de titre affiché n'est pas bloquant.
+ }
+ }, []);
+
+ useFocusEffect(useCallback(() => { loadEquippedTitle(); }, [loadEquippedTitle]));
+
// Enregistre le ScrollView et la fonction de re-mesure dans le contexte tutoriel.
// Indispensable pour que scrollY: 260 de l'étape profile_vitrine fonctionne,
// car sans ça la vitrine reste hors-écran au moment de la mesure.
@@ -118,8 +142,43 @@ export default function ProfileScreen({ navigation }) {
}, [refetchUser, reloadFeatured, reloadDevSettings]),
);
+ // ─── Records mis en avant (Section III) ──────────────────────────────────
+ // Source de vérité backend (ExerciseRecord), pas les logs locaux — pour
+ // rester identique à ce que voient les amis sur leur profil (getFriendProfile).
+ const [myRecords, setMyRecords] = useState([]);
+ const [recordsPickerVisible, setRecordsPickerVisible] = useState(false);
+
+ const loadMyRecords = useCallback(async () => {
+ try {
+ const res = await getMyRecords();
+ setMyRecords(Array.isArray(res.records) ? res.records : []);
+ } catch (_) {
+ // Best-effort — ne doit jamais bloquer l'affichage du profil.
+ }
+ }, []);
+
+ useFocusEffect(useCallback(() => { loadMyRecords(); }, [loadMyRecords]));
+
+ const showcasedRecordNames = user?.showcasedRecords ?? [];
+
+ const records = useMemo(() => {
+ const byName = new Map(myRecords.map((r) => [r.exercice, r]));
+ return showcasedRecordNames.map((name) => {
+ const r = byName.get(name);
+ const meta = resolveExerciseMeta(name);
+ return {
+ name,
+ group: meta.group,
+ icon: meta.icon,
+ prWeight: r ? r.maxPoids : null,
+ prEstimate1RM: r ? Math.round(r.maxPoids * (1 + r.maxReps / 30) * 10) / 10 : null,
+ hasData: !!r,
+ subtitle: r ? `${r.maxReps} reps max` : undefined,
+ };
+ });
+ }, [myRecords, showcasedRecordNames]);
+
// ─── Computed values ──────────────────────────────────────────────────────
- const records = useMemo(() => getPersonalRecords(logs, MAJOR_EXERCISES), [logs]);
const heatmap = useMemo(() => aggregateActivityHeatmap(logs), [logs]);
const totalSessions = logs.length;
const totalActiveDays = useMemo(() => {
@@ -139,6 +198,19 @@ export default function ProfileScreen({ navigation }) {
return [...base, { ...ULTIMATE_TROPHY, unlocked: ultimateUnlocked, naturalUnlocked: ultimateUnlocked }];
}, [level, totalSessions, logs, totalXP, trophyOverrides]);
+ // Synchronise les trophées locaux NATURELLEMENT débloqués (jamais les
+ // overrides God Mode) vers le backend, pour qu'ils apparaissent sur le
+ // profil public consulté par les amis. Dédupliqué par un ref (déclenche au
+ // plus une requête par changement réel de l'ensemble débloqué).
+ const lastSyncedRef = useRef('');
+ useEffect(() => {
+ const unlockedIds = evaluatedTrophies.filter((t) => t.naturalUnlocked).map((t) => t.id);
+ const key = [...unlockedIds].sort().join(',');
+ if (key === lastSyncedRef.current || unlockedIds.length === 0) return;
+ lastSyncedRef.current = key;
+ syncLocalAchievements(unlockedIds).catch(() => { lastSyncedRef.current = ''; });
+ }, [evaluatedTrophies]);
+
// Active profile theme (null when 'auto' or not set)
const activeTheme = useMemo(() => {
const t = getTheme(profileThemeId);
@@ -146,9 +218,10 @@ export default function ProfileScreen({ navigation }) {
}, [profileThemeId]);
// Background variant: theme overrides level-based flags
- const isElite = activeTheme ? ['elite','legend','god'].includes(activeTheme.bgVariant) : level >= 91;
- const isLegend = activeTheme ? ['legend','god'].includes(activeTheme.bgVariant) : level >= 171;
- const isGod = activeTheme ? activeTheme.bgVariant === 'god' : level >= 200;
+ const isElite = activeTheme ? ['elite','legend','god','blood'].includes(activeTheme.bgVariant) : level >= 91;
+ const isLegend = activeTheme ? ['legend','god','blood'].includes(activeTheme.bgVariant) : level >= 171;
+ const isGod = activeTheme ? activeTheme.bgVariant === 'god' : level >= 200;
+ const isBlood = activeTheme?.bgVariant === 'blood';
// rank is used for QuickBtn accent — also override with theme
const rank = useMemo(() => {
@@ -156,7 +229,7 @@ export default function ProfileScreen({ navigation }) {
return { ...realRank, color: activeTheme.accentColor || realRank.color };
}, [activeTheme, realRank]);
- const bgColors = buildBgGradient(isGod, isLegend, isElite);
+ const bgColors = buildBgGradient(isGod, isLegend, isElite, isBlood);
const topPad = insets.top + 16;
if (profileLoading && !user && logsLoading) {
@@ -218,10 +291,12 @@ export default function ProfileScreen({ navigation }) {
shapeId={shapeId}
colorId={colorId}
profileTheme={activeTheme}
+ titleLabel={equippedTitleMeta?.label}
+ titleColor={equippedTitleMeta ? RARITY_META[equippedTitleMeta.rarity]?.color : undefined}
/>
@@ -248,8 +323,30 @@ export default function ProfileScreen({ navigation }) {
onPress={() => navigation && navigation.navigate('TrophyRoom')}
accentColor={isElite ? rank.color : null}
/>
+ setTitlePickerVisible(true)}
+ accentColor={isElite ? rank.color : null}
+ />
+ {/* ── Inventaire (bannière pleine largeur — trop à l'étroit dans quickActions) ── */}
+ navigation && navigation.navigate('Inventory')}
+ activeOpacity={0.85}
+ >
+
+
+
+
+ Inventaire
+ Coffres, objets & récompenses
+
+
+
+
{/* ── Vitrine de trophées ── */}
navigation && navigation.navigate('TrophyRoom')}>
@@ -264,14 +361,14 @@ export default function ProfileScreen({ navigation }) {
{/* ── Records personnels ── */}
-
+ setRecordsPickerVisible(true)} seeAllLabel="Choisir">
-
+
{/* ── Activité 12 mois ── */}
-
+
@@ -303,28 +400,41 @@ export default function ProfileScreen({ navigation }) {
currentShapeId={shapeId}
currentColorId={colorId}
playerLevel={level}
+ unlockedCosmetics={user?.unlockedCosmetics ?? []}
userInitial={userInitial}
onSelectShape={selectShape}
onSelectColor={selectColor}
/>
+ { setTitlePickerVisible(false); loadEquippedTitle(); }}
+ />
+
{activeChapterId === 'profile' && (
)}
+
+ { refetchUser(); loadMyRecords(); }}
+ onClose={() => setRecordsPickerVisible(false)}
+ />
);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
-function Section({ title, children, onSeeAll }) {
+function Section({ title, children, onSeeAll, seeAllLabel = 'Voir tout' }) {
return (
{title}
{onSeeAll && (
- Voir tout
+ {seeAllLabel}
)}
@@ -347,7 +457,7 @@ function QuickBtn({ icon, label, onPress, accentColor }) {
return (
{ haptics.success(); if (onPress) onPress(); }}
activeOpacity={0.82}
>
@@ -373,9 +483,9 @@ const styles = StyleSheet.create({
heroWrapper: { position: 'relative' },
streakWrap: { marginTop: 10 },
- quickActions: { flexDirection: 'row', gap: 10, marginTop: 12 },
+ quickActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 },
quickBtn: {
- flex: 1,
+ width: '47.5%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@@ -388,6 +498,31 @@ const styles = StyleSheet.create({
},
quickBtnText: { color: Colors.textSecondary, fontSize: 12, fontWeight: '700' },
+ inventoryBanner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginTop: 10,
+ backgroundColor: Colors.glassBg,
+ paddingVertical: 13,
+ paddingHorizontal: 14,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: Colors.glassBorder,
+ },
+ inventoryIconBox: {
+ width: 38,
+ height: 38,
+ borderRadius: 11,
+ backgroundColor: 'rgba(254,116,57,0.14)',
+ borderWidth: 1,
+ borderColor: 'rgba(254,116,57,0.30)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ inventoryTitle: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' },
+ inventorySub: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 },
+
section: { marginTop: 26 },
sectionRow: {
flexDirection: 'row', alignItems: 'center',
diff --git a/front/src/screens/Profile/RankRoadmapScreen.js b/front/src/screens/Profile/RankRoadmapScreen.js
index 3d79128..2545907 100644
--- a/front/src/screens/Profile/RankRoadmapScreen.js
+++ b/front/src/screens/Profile/RankRoadmapScreen.js
@@ -10,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Colors } from '../../constants/theme';
-import { getRank, xpToLevel } from '../../services/stats.service';
+import { xpToLevel } from '../../services';
import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
import { FRAME_DEFS } from '../../components/profile/AvatarFrame';
@@ -22,7 +22,7 @@ const RANKS = [
levelRange: '0 – 10',
minLevel: 0,
maxLevel: 10,
- color: '#FE7439',
+ color: Colors.primary,
gradientColors: ['#431A08', '#7C2D12'],
frameId: 'none',
perks: [
@@ -44,6 +44,7 @@ const RANKS = [
perks: [
'Cadre Bronze débloqué (Niv. 11)',
'Forme Hexagone débloquée (Niv. 11)',
+ 'Inventaire débloqué : coffres à ouvrir en cumulant tes séances',
],
icon: 'ribbon',
},
@@ -52,7 +53,7 @@ const RANKS = [
levelRange: '31 – 50',
minLevel: 31,
maxLevel: 50,
- color: '#22C55E',
+ color: Colors.valid,
gradientColors: ['#052E16', '#14532D'],
frameId: 'silver',
perks: [
@@ -81,7 +82,7 @@ const RANKS = [
levelRange: '71 – 90',
minLevel: 71,
maxLevel: 90,
- color: '#6E6AF0',
+ color: Colors.secondaryAccent,
gradientColors: ['#1E1B4B', '#2E2A7A'],
frameId: 'warrior',
perks: [
@@ -95,7 +96,7 @@ const RANKS = [
levelRange: '91 – 110',
minLevel: 91,
maxLevel: 110,
- color: '#6E6AF0',
+ color: Colors.secondaryAccent,
gradientColors: ['#0F0F1A', '#1C1C38'],
frameId: 'elite',
perks: [
@@ -110,7 +111,7 @@ const RANKS = [
levelRange: '111 – 140',
minLevel: 111,
maxLevel: 140,
- color: '#8B5CF6',
+ color: Colors.rankViolet,
gradientColors: ['#2E1065', '#4C1D95'],
frameId: 'master',
perks: [
@@ -124,7 +125,7 @@ const RANKS = [
levelRange: '141 – 170',
minLevel: 141,
maxLevel: 170,
- color: '#A855F7',
+ color: Colors.rankPurple,
gradientColors: ['#3B0764', '#581C87'],
frameId: 'grandmaster',
perks: [
@@ -138,7 +139,7 @@ const RANKS = [
levelRange: '171 – 199',
minLevel: 171,
maxLevel: 199,
- color: '#C084FC',
+ color: Colors.legendAccent,
gradientColors: ['#4A044E', '#701A75'],
frameId: 'legend',
perks: [
@@ -155,7 +156,7 @@ const RANKS = [
levelRange: 'Niv. 200',
minLevel: 200,
maxLevel: 200,
- color: '#FFD700',
+ color: Colors.gold,
gradientColors: ['#431407', '#78350F'],
frameId: 'god',
perks: [
diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js
index 0b7e95d..c29b1b0 100644
--- a/front/src/screens/Profile/SettingsScreen.js
+++ b/front/src/screens/Profile/SettingsScreen.js
@@ -1,28 +1,34 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import {
View, Text, StyleSheet, ScrollView, TouchableOpacity,
- StatusBar, Switch, Alert, TextInput, ActivityIndicator, Modal,
+ StatusBar, Switch, TextInput, ActivityIndicator, Modal, Share,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Colors } from '../../constants/theme';
+import { ConfirmModal } from '../../components/common';
+import { InfoModal } from '../../components/common';
import { useAuth } from '../../context/AuthContext';
import { useUser } from '../../context/UserContext';
import { useToast } from '../../context/ToastContext';
import { useSavedWorkouts } from '../../context/SavedWorkoutsContext';
import { useQuests } from '../../context/QuestContext';
import { useCustomExercises } from '../../context/CustomExercisesContext';
-import { deleteAccount } from '../../services/auth.service';
+import { deleteAccount } from '../../services';
import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
import {
xpToLevel, xpForLevel, computeStreak,
debugAddXP, debugSetLevel, debugAddSessions,
debugSimulateReps, debugSetStreak, debugClearDebugLogs,
debugResetDailyXP,
-} from '../../services/stats.service';
+} from '../../services';
import { PROFILE_THEMES, isThemeLocked } from '../../data/profileThemes';
import { TROPHY_CATALOG, TROPHY_CATEGORIES, ULTIMATE_TROPHY, evaluateTrophies } from '../../data/trophyCatalog';
-import { useDevSettings } from '../../hooks/useDevSettings';
+import { COLLECTION_CATEGORY, BACKEND_CATEGORY_MAP } from '../../data/backendTrophyCategories';
+import { getAchievements } from '../../services';
+import { normalizeAchievement } from '../../components/profile/AchievementShowcase';
+import { TrophyIcon } from '../../components/profile/TrophySlot';
+import { useDevSettings } from '../../hooks';
import { useFocusEffect } from '@react-navigation/native';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
@@ -32,7 +38,13 @@ import {
fireTestNotification,
scheduleDailyReminder,
cancelDailyReminder,
-} from '../../services/notificationService';
+} from '../../services';
+import {
+ syncBackendLevel, giveChests, generateMockSocial, giveAllItems,
+ simulateChestsOpened, simulateReferral, simulateBirthday,
+ simulateGroup, simulateActivityEvent, simulateStreakBreak, simulateShakeSelf,
+ simulateSearchableFriend, simulateLobbyInvite, giveAllTitles,
+} from '../../services';
const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1';
const UNIT_DIST_KEY = 'athly:unit:distance:v1';
@@ -44,12 +56,12 @@ const SEP = 'rgba(255,255,255,0.07)';
const GOLD_BG = 'rgba(255,215,0,0.06)';
const GOLD_BDR = 'rgba(255,215,0,0.22)';
-// ─── Tap trigger to reveal dev section : 10 taps ─────────────────────────────
-const DEV_TAP_TARGET = 10;
+// ─── Tap trigger to reveal dev section : 20 taps ─────────────────────────────
+const DEV_TAP_TARGET = 20;
export default function SettingsScreen({ navigation }) {
const { signOut } = useAuth();
- const { setUser } = useUser();
+ const { user, setUser, refetch: refetchUser } = useUser();
const { showToast } = useToast();
const { totalXP, sessionLogs, activityLogs, refresh, clearAll: clearWorkoutLogs } = useWorkoutLogs();
const { clearAll: clearSavedWorkouts } = useSavedWorkouts();
@@ -145,9 +157,12 @@ export default function SettingsScreen({ navigation }) {
const [targetLevel, setTargetLevel] = useState('');
const [targetStreak, setTargetStreak] = useState('');
const [targetXP, setTargetXP] = useState('');
+ const [targetChests, setTargetChests] = useState('1');
const [simLoading, setSimLoading] = useState(false);
const [simFeedback, setSimFeedback] = useState('');
const [trophyExpanded, setTrophyExpanded] = useState(false);
+ const [testFriendTag, setTestFriendTag] = useState(null);
+ const [infoModal, setInfoModal] = useState(null); // { title, body, destructive? }
const [deleteModal1, setDeleteModal1] = useState(false);
const [deleteModal2, setDeleteModal2] = useState(false);
@@ -191,17 +206,32 @@ export default function SettingsScreen({ navigation }) {
}
}, [tapCount, devVisible]);
+ // Récupère le profil backend au montage : garantit que referralCode est
+ // présent (généré paresseusement par getMe pour les comptes existants).
+ useEffect(() => { refetchUser(); }, [refetchUser]);
+
+ const handleShareReferral = useCallback(async () => {
+ if (!user?.referralCode) return;
+ try {
+ await Share.share({
+ message: `Rejoins-moi sur Athly ! Utilise mon code de parrainage ${user.referralCode} à l'inscription : on gagne chacun un Gel de Streak et un Coupon de Niveau.`,
+ });
+ } catch (_) {
+ // Partage annulé ou indisponible : rien à faire
+ }
+ }, [user?.referralCode]);
+
const handleNotifToggle = useCallback(async (val) => {
if (val) {
const granted = await requestNotificationPermissions();
if (!granted) {
- Alert.alert('Notifications désactivées', 'Activez les notifications Athly dans les réglages de votre appareil.');
+ setInfoModal({ title: 'Notifications désactivées', body: 'Activez les notifications Athly dans les réglages de votre appareil.' });
return;
}
try {
await scheduleDailyReminder();
} catch (e) {
- Alert.alert('Erreur', 'Impossible de planifier la notification.');
+ setInfoModal({ title: 'Erreur', body: 'Impossible de planifier la notification.', destructive: true });
return;
}
setNotifEnabled(true);
@@ -224,7 +254,7 @@ export default function SettingsScreen({ navigation }) {
try {
setSimLoading(true);
await fireTestNotification(type);
- showFeedback(type === 'orange' ? 'Notif orange dans 3 s... 🔥' : 'Notif violette dans 3 s... 👀');
+ showFeedback(type === 'orange' ? 'Notif orange dans 3 s...' : 'Notif violette dans 3 s...');
} catch (e) {
showFeedback('Erreur : ' + (e?.message || 'inconnue'));
} finally {
@@ -234,15 +264,11 @@ export default function SettingsScreen({ navigation }) {
const handleGodMode = useCallback(async (val) => {
await setGodMode(val);
- if (val) Alert.alert('God Mode activé 🔥', 'Utilisez la console ci-dessous pour simuler votre progression.');
+ if (val) setInfoModal({ title: 'God Mode activé', body: 'Utilisez la console ci-dessous pour simuler votre progression.' });
}, [setGodMode]);
- const handleLogout = () => {
- Alert.alert('Déconnexion', 'Tu vas être déconnecté.', [
- { text: 'Annuler', style: 'cancel' },
- { text: 'Déconnexion', style: 'destructive', onPress: signOut },
- ]);
- };
+ const [logoutConfirmVisible, setLogoutConfirmVisible] = useState(false);
+ const handleLogout = () => setLogoutConfirmVisible(true);
const runSim = useCallback(async (fn, successMsg) => {
try {
@@ -257,10 +283,24 @@ export default function SettingsScreen({ navigation }) {
}
}, [refresh, showFeedback]);
+ // debugSetLevel (AsyncStorage local) + syncBackendLevel (user.level backend)
+ // dans la foulée : sans ça, le niveau simulé par God Mode divergeait
+ // silencieusement du niveau réellement stocké côté serveur — visible par
+ // exemple dans le sélecteur de titres, qui vérifie le niveau backend (comme
+ // le gating des coffres). syncBackendLevel échoue silencieusement en
+ // production (404, endpoint dev-only) : ne bloque jamais la simulation locale.
+ const setLevelEverywhere = async (n) => {
+ await debugSetLevel(n);
+ try {
+ await syncBackendLevel(n);
+ await refetchUser();
+ } catch (_) { /* prod : endpoint indisponible, ignoré */ }
+ };
+
const handleSetLevel = () => {
const n = parseInt(targetLevel, 10);
if (!targetLevel || isNaN(n) || n < 0 || n > 200) { showFeedback('Niveau invalide (0–200)'); return; }
- runSim(() => debugSetLevel(n), `Niveau ${n} appliqué ✓`);
+ runSim(() => setLevelEverywhere(n), `Niveau ${n} appliqué ✓`);
};
const handleAddXP = () => runSim(() => debugAddXP(1000), '+1000 XP injectés ✓');
const handleAddCustomXP = () => {
@@ -269,10 +309,10 @@ export default function SettingsScreen({ navigation }) {
runSim(() => debugAddXP(n), `+${n.toLocaleString('fr-FR')} XP injectés ✓`);
setTargetXP('');
};
- const handlePlusLevel = () => runSim(() => debugSetLevel(level + 1), `Passage au niveau ${level + 1} ✓`);
+ const handlePlusLevel = () => runSim(() => setLevelEverywhere(level + 1), `Passage au niveau ${level + 1} ✓`);
const handleMinusLevel = () => {
if (level <= 0) { showFeedback('Déjà au niveau 0'); return; }
- runSim(() => debugSetLevel(level - 1), `Retour au niveau ${level - 1} ✓`);
+ runSim(() => setLevelEverywhere(level - 1), `Retour au niveau ${level - 1} ✓`);
};
const handleGenSessions = () => runSim(() => debugAddSessions(50), '50 séances injectées ✓');
const handleSimReps = () => runSim(() => debugSimulateReps(3000), '~3000 répétitions simulées ✓');
@@ -282,17 +322,271 @@ export default function SettingsScreen({ navigation }) {
runSim(() => debugSetStreak(n), `Streak ${n} jours appliqué ✓`);
};
const handleResetDailyXP = () => runSim(debugResetDailyXP, 'Quota XP quotidien réinitialisé ✓');
- const handleClearDebug = () => {
- Alert.alert('Effacer les logs DEBUG', 'Les vraies séances restent intactes.', [
- { text: 'Annuler', style: 'cancel' },
- { text: 'Effacer', style: 'destructive', onPress: () => runSim(debugClearDebugLogs, 'Logs DEBUG effacés ✓') },
- ]);
+ const [clearDebugConfirmVisible, setClearDebugConfirmVisible] = useState(false);
+ const handleClearDebug = () => setClearDebugConfirmVisible(true);
+ const confirmClearDebug = () => {
+ setClearDebugConfirmVisible(false);
+ runSim(debugClearDebugLogs, 'Logs DEBUG effacés ✓');
};
const handleClearOverrides = () => {
clearTrophyOverrides();
showFeedback('Overrides trophées réinitialisés ✓');
};
+ // Le niveau simulé ci-dessus (debugSetLevel…) reste 100% local (AsyncStorage) —
+ // il ne débloque pas les fonctionnalités gated côté serveur (coffres, niveau 11+).
+ // Ce bouton pousse le niveau affiché vers le user.level backend pour tester
+ // ces features sans dizaines de vraies séances. Bloqué en production (404).
+ const handleSyncBackendLevel = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await syncBackendLevel(level);
+ await refetchUser();
+ showFeedback(`Backend synchronisé : niveau ${res.level} (${res.rank}) ✓`);
+ } catch (e) {
+ const msg = e?.status === 404
+ ? 'Indisponible en production.'
+ : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [level, refetchUser, showFeedback]);
+
+ // Crédite des CHEST_KEY backend pour tester l'ouverture de coffre sans
+ // attendre les paliers de 5h de séance. Bloqué en production (404).
+ const handleGiveChests = useCallback(async () => {
+ const n = parseInt(targetChests, 10);
+ if (!targetChests || isNaN(n) || n < 1 || n > 50) { showFeedback('Quantité invalide (1–50)'); return; }
+ try {
+ setSimLoading(true);
+ const res = await giveChests(n);
+ showFeedback(`+${n} coffre(s) ✓ (total : ${res.chestCount})`);
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [targetChests, showFeedback]);
+
+ // Génère 2 amis acceptés + 1 demande en attente pour tester l'écran Social
+ // (Classement, création de Groupe, Accepter/Refuser) sans dépendre de vrais
+ // comptes tiers. Idempotent côté backend. Bloqué en production (404).
+ const handleMockSocial = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await generateMockSocial();
+ showFeedback(res.message || 'Réseau social de test généré ✓');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Crée un compte de test PAS déjà ami — seul moyen de tester en solo le
+ // parcours complet "Ajouter un ami" (recherche par tag, preview, envoi).
+ // Bloqué en production (404).
+ const handleSimulateSearchableFriend = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateSearchableFriend();
+ // Le tag doit rester lisible le temps de changer d'écran (Réglages →
+ // Social) pour le saisir dans "Ajouter un ami" — le feedback normal
+ // s'efface après 2,5s, largement trop court pour ça. Affiché à part,
+ // de façon persistante, tant que ce compte de test reste valide.
+ if (res.tag) {
+ setTestFriendTag(res.tag);
+ showFeedback('Compte de test créé - tag affiché ci-dessous ↓');
+ } else {
+ showFeedback(res.message || 'Compte de test créé');
+ }
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Crée un lobby Multi avec un coéquipier factice (isTestBot) et envoie une
+ // vraie notification push d'invitation à soi-même — seul moyen de tester en
+ // solo le parcours complet Lobby Multi (invitation, rejoindre, prêt, séance,
+ // bonus de groupe). Bloqué en production (404).
+ const handleSimulateLobbyInvite = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateLobbyInvite();
+ showFeedback(res.message || 'Invitation de test créée');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Débloque tous les titres du catalogue (Section X) sans passer par leurs
+ // conditions réelles — teste le sélecteur en un clic. Bloqué en production (404).
+ const handleGiveAllTitles = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await giveAllTitles();
+ await refetchUser();
+ showFeedback(res.message || 'Tous les titres débloqués ✓');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback, refetchUser]);
+
+ // Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques
+ // Uniques réclamables) pour tout tester en un clic. Bloqué en production (404).
+ const handleGiveAllItems = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await giveAllItems();
+ await refetchUser();
+ showFeedback(res.message || 'Tous les objets ajoutés ✓');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback, refetchUser]);
+
+ // Formatte un retour "+N trophée(s) débloqué(s)" à partir de newlyUnlocked,
+ // partagé par les 3 simulateurs de trophées backend ci-dessous.
+ const feedbackWithUnlocks = useCallback((baseMessage, newlyUnlocked) => {
+ if (newlyUnlocked && newlyUnlocked.length > 0) {
+ return `${baseMessage} · ${newlyUnlocked.length} trophée(s) débloqué(s) ✓`;
+ }
+ return `${baseMessage} (déjà débloqué)`;
+ }, []);
+
+ // Incrémente totalChestsOpened sans vraies ouvertures de coffre — permet de
+ // tester les trophées CHEST_1…CHEST_200 et le thème Rouge Sang (100 coffres)
+ // sans enchaîner des dizaines d'ouvertures manuelles. Bloqué en prod (404).
+ const handleSimulateChests = useCallback(async () => {
+ const n = parseInt(targetChests, 10);
+ if (!targetChests || isNaN(n) || n < 1 || n > 250) { showFeedback('Quantité invalide (1–250)'); return; }
+ try {
+ setSimLoading(true);
+ const res = await simulateChestsOpened(n);
+ await refetchUser();
+ showFeedback(feedbackWithUnlocks(`+${n} coffre(s) simulé(s) (total : ${res.totalChestsOpened})`, res.newlyUnlocked));
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [targetChests, showFeedback, refetchUser, feedbackWithUnlocks]);
+
+ // Crée un filleul factice pour débloquer FIRST_REFERRAL (parrainage) sans
+ // avoir à créer un vrai second compte. Bloqué en production (404).
+ const handleSimulateReferral = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateReferral();
+ await refetchUser();
+ showFeedback(feedbackWithUnlocks('Parrainage simulé', res.newlyUnlocked));
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback, refetchUser, feedbackWithUnlocks]);
+
+ // Force la date de naissance à aujourd'hui pour débloquer BIRTHDAY_SET et
+ // BIRTHDAY_CELEBRATED sans attendre le vrai jour J. Bloqué en prod (404).
+ const handleSimulateBirthday = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateBirthday();
+ await refetchUser();
+ showFeedback(feedbackWithUnlocks('Anniversaire simulé', res.newlyUnlocked));
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback, refetchUser, feedbackWithUnlocks]);
+
+ // ── Vague 1 : groupe, météo, activité, Hall of Shame, secouer ────────────
+ // Ces 4 outils sont le SEUL moyen de tester ces fonctionnalités en solo :
+ // elles dépendent toutes d'un groupe à plusieurs membres et/ou d'un second
+ // appareil, ce qu'un testeur seul ne peut pas reproduire manuellement.
+
+ // Crée un groupe de test avec 3 coéquipiers factices (Prêt/Actif/Validé) —
+ // teste la Météo des séances, le multiplicateur de groupe et sert de base
+ // aux 3 autres outils ci-dessous. Bloqué en production (404).
+ const handleSimulateGroup = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateGroup();
+ showFeedback(res.message || 'Groupe de test créé');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Publie un événement factice au nom d'un coéquipier — teste l'ActivityFeedModal
+ // et les réactions. Nécessite d'avoir d'abord lancé "Simuler un groupe".
+ const handleSimulateActivityEvent = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateActivityEvent();
+ showFeedback(res.message || 'Événement simulé');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Recule lastValidatedDate du groupe pour déclencher le Hall of Shame au
+ // prochain chargement de l'onglet Groupe.
+ const handleSimulateStreakBreak = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateStreakBreak();
+ showFeedback(res.message || 'Rupture de streak simulée');
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
+ // Envoie une vraie notification push au propre appareil du testeur — seul
+ // moyen de vérifier de bout en bout que l'infra push fonctionne sans
+ // second compte/appareil pour recevoir la notification.
+ const handleSimulateShakeSelf = useCallback(async () => {
+ try {
+ setSimLoading(true);
+ const res = await simulateShakeSelf();
+ showFeedback(res.message || (res.pushed ? 'Notification envoyée' : 'Échec d\'envoi'));
+ } catch (e) {
+ const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue');
+ showFeedback('Erreur : ' + msg);
+ } finally {
+ setSimLoading(false);
+ }
+ }, [showFeedback]);
+
const handleLockDevSection = useCallback(async () => {
setDevVisible(false);
setTapCount(0);
@@ -332,9 +626,45 @@ export default function SettingsScreen({ navigation }) {
() => evaluateTrophies(level, sessionLogs.length, sessionLogs, totalXP, trophyOverrides),
[level, sessionLogs, totalXP, trophyOverrides],
);
- const ultimateUnlocked = evaluatedTrophies.every((t) => t.unlocked);
- const unlockedThemes = PROFILE_THEMES.filter((t) => !isThemeLocked(t, level));
+ // Trophées backend V2 (anniversaire, collection d'objets, coffres, social) —
+ // sans eux, le panneau God Mode n'affichait que les 40 trophées locaux.
+ // Lecture seule ici : pas d'override dev possible sur des données serveur.
+ const [backendAchievements, setBackendAchievements] = useState([]);
+ useFocusEffect(
+ useCallback(() => {
+ let cancelled = false;
+ getAchievements()
+ .then((res) => { if (!cancelled) setBackendAchievements(res.achievements ?? []); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, []),
+ );
+
+ const normalizedBackendTrophies = useMemo(
+ () => backendAchievements.map((a) => ({
+ ...normalizeAchievement(a),
+ category: BACKEND_CATEGORY_MAP[a.category] || 'collection',
+ isBackend: true,
+ })),
+ [backendAchievements],
+ );
+
+ const allTrophyCategories = useMemo(() => [...TROPHY_CATEGORIES, COLLECTION_CATEGORY], []);
+ const fullTrophyList = useMemo(
+ () => [...evaluatedTrophies, ...normalizedBackendTrophies],
+ [evaluatedTrophies, normalizedBackendTrophies],
+ );
+
+ // Le Trophée Ultime exige la collection complète (local + backend) — voir
+ // la même règle dans TrophyRoomScreen.js.
+ const ultimateUnlocked = backendAchievements.length > 0 && fullTrophyList.every((t) => t.unlocked);
+
+ const unlockedCosmetics = user?.unlockedCosmetics ?? [];
+ const totalChestsOpened = user?.totalChestsOpened ?? 0;
+ const unlockedThemes = PROFILE_THEMES.filter((t) => !isThemeLocked(t, level, unlockedCosmetics));
+ const bloodSangTheme = PROFILE_THEMES.find((t) => t.id === 'blood_sang');
+ const bloodSangLocked = bloodSangTheme ? isThemeLocked(bloodSangTheme, level, unlockedCosmetics) : false;
return (
@@ -355,6 +685,29 @@ export default function SettingsScreen({ navigation }) {
+ {/* ═══ PARRAINAGE ═══════════════════════════════════════════════════════ */}
+
+
+
+
+ {user?.referralCode || '…'}
+
+
+
+
+
+
+
+ Partage ton code : ton filleul et toi recevez chacun un Gel de Streak et un Coupon
+ de Niveau, et vous devenez amis automatiquement.
+
+
{/* ═══ UNITÉS ═══════════════════════════════════════════════════════════ */}
@@ -386,7 +739,7 @@ export default function SettingsScreen({ navigation }) {
{PROFILE_THEMES.map((theme) => {
- const locked = isThemeLocked(theme, level);
+ const locked = isThemeLocked(theme, level, unlockedCosmetics);
const selected = profileThemeId === theme.id;
return (
+ {bloodSangLocked && (
+
+
+
+ Rouge Sang : déblocable après avoir ouvert 100 coffres (Actuel : {Math.min(totalChestsOpened, 100)}/100)
+
+
+ )}
{/* ═══ NOTIFICATIONS ════════════════════════════════════════════════════ */}
@@ -468,143 +829,314 @@ export default function SettingsScreen({ navigation }) {
- {/* ── NIVEAU & XP ── */}
-
-
-
-
-
-
-
- = 200} flex />
-
-
-
-
-
-
- XP requis Niv.{level + 1} : {xpForLevel(level + 1).toLocaleString('fr-FR')}
+ {/* ── PROGRESSION (Niveau, XP, sync backend) ── */}
+
+
+
+
+
+
+
+ = 200} flex />
+
+
+
+
+
+
+ XP requis Niv.{level + 1} : {xpForLevel(level + 1).toLocaleString('fr-FR')}
- {/* ── SIMULATION ── */}
-
-
-
-
-
- Injecte des logs réalistes sur les N derniers jours.
-
- {/* ── STREAK ── */}
-
-
-
-
-
+
+
+ Le niveau ci-dessus est local uniquement. Les coffres et fonctionnalités
+ serveur (niveau 11+) lisent le niveau backend, synchronise pour les tester.
+
+
+
+
+
- {/* ── TROPHÉES ── */}
-
+ {/* ── INVENTAIRE & COFFRES ── */}
+
+
+ Coffres injectés directement en base, pour tester l'Inventaire
+ sans dizaines de vraies actions.
+
+
+
+
+
+
+
+ « + Coffre(s) » ajoute des CHEST_KEY à ouvrir manuellement. « Simuler ouverts »
+ incrémente directement le compteur de coffres ouverts (trophées CHEST_1…CHEST_200,
+ thème Rouge Sang à 100).
+
+
+
+
+
+ Ajoute 1 exemplaire de chaque objet (consommables + cosmétiques Uniques
+ réclamables) dans l'inventaire, pour tout tester d'un coup.
+
+
- {/* Statut Trophée Ultime */}
-
-
-
- {ULTIMATE_TROPHY.label}
+ {/* ── RÉSEAU SOCIAL ── */}
+
+
+ Faux amis générés directement en base, pour tester l'écran Social
+ sans dépendre de vrais comptes tiers.
-
- {ultimateUnlocked ? '✓ Débloqué !' : `${evaluatedTrophies.filter(t => t.unlocked).length}/${TROPHY_CATALOG.length}`}
+
+
+
+
+ Crée FauxAmi_1 et FauxAmi_2 (amis acceptés, pour Classement et Groupe)
+ et FauxAmi_3 (demande en attente, pour Accepter/Refuser). Rejouable sans doublons.
+
+
+
+
+
+
+ Débloquent respectivement le trophée « Recruteur Athly » (parrainage) et les
+ trophées anniversaire, sans attendre un vrai filleul ou le vrai jour J.
-
- {/* Actions de masse */}
-
- {
- TROPHY_CATALOG.forEach(t => setTrophyOverride(t.id, true));
- showFeedback('Tous les trophées débloqués ✓');
- }}
- disabled={simLoading}
- flex
- />
- { clearTrophyOverrides(); showFeedback('Overrides réinitialisés ✓'); }}
- disabled={simLoading}
- flex
- variant="dim"
- />
-
+
+
+
+
+
+ Crée un compte "TestAmi" PAS déjà ami avec un # généré aléatoirement.
+ Le tag exact reste affiché juste en dessous (pas le message temporaire,
+ trop court pour changer d'écran) - saisis-le tel quel dans "Ajouter un
+ ami" côté Social. Ne tape jamais "0000", ce n'est qu'un exemple de format.
+
+ {testFriendTag && (
+
+ TAG DU COMPTE DE TEST
+ {testFriendTag}
+
+ )}
+
+
+ {/* ── GROUPE DE STREAK (V2) ── */}
+
+
+ Ces outils sont le seul moyen de tester la Météo des séances, le flux
+ d'activité, le Hall of Shame et le bouton Secouer sans un second
+ compte/appareil.
+
+
+
+
+
+ Crée un groupe avec 3 coéquipiers factices, un par statut de Météo des
+ séances testable (Prêt / Actif / Validé - le 4e, En sommeil,
+ s'obtient en ne touchant à aucun des trois). Rejouable sans doublons.
+
+
+
+
+
+ Publie un PR battu ou un coffre Légendaire au nom d'un coéquipier -
+ déclenche l'ActivityFeedModal au prochain lancement. Nécessite d'avoir
+ d'abord simulé un groupe.
+
+
+
+
+
+ Recule la dernière validation du groupe pour déclencher le Hall of Shame
+ dès le prochain chargement de l'onglet Groupe.
+
+
+
+
+
+ Envoie une vraie notification push à ton propre appareil, avec le texte
+ troll du bouton Secouer - vérifie l'infra push de bout en bout.
+
+
- {/* Accordéon — liste individuelle */}
- setTrophyExpanded(v => !v)}
- activeOpacity={0.75}
- >
-
- Gestion individuelle des trophées
+ {/* ── LOBBY MULTI (V2) ── */}
+
+
+
+
+
+ Crée un lobby avec un coéquipier factice et t'envoie une vraie
+ notification push d'invitation - teste tout le parcours en solo :
+ popup "X t'invite", rejoindre, se déclarer prêt, faire sa séance,
+ et voir le bonus XP de groupe à la fin (le coéquipier factice suit
+ automatiquement chacun de tes statuts).
-
-
+
- {trophyExpanded && TROPHY_CATEGORIES.map((cat) => {
- const catTrophies = evaluatedTrophies.filter((t) => t.category === cat.id);
- if (catTrophies.length === 0) return null;
- return (
-
- {cat.label}
- {catTrophies.map((t) => (
-
-
-
-
-
-
- {t.label}
-
- {t.condition}
-
-
- {!t.naturalUnlocked && trophyOverrides[t.id] === true && (
- DEV
+ {/* ── TITRES (Section X) ── */}
+
+
+
+
+
+ Débloque directement les 17 titres du catalogue, sans passer par leurs
+ conditions réelles - teste le sélecteur de titres en un clic.
+
+
+
+ {/* ── SIMULATION DE SÉANCES ── */}
+
+
+
+
+
+ Injecte des logs réalistes sur les N derniers jours.
+
+
+
+
+
+
+
+
+ {/* ── TROPHÉES ── */}
+
+ {/* Statut Trophée Ultime */}
+
+
+
+ {ULTIMATE_TROPHY.label}
+
+
+ {ultimateUnlocked ? '✓ Débloqué !' : `${fullTrophyList.filter(t => t.unlocked).length}/${fullTrophyList.length}`}
+
+
+
+ {/* Actions de masse */}
+
+ {
+ TROPHY_CATALOG.forEach(t => setTrophyOverride(t.id, true));
+ showFeedback('Tous les trophées débloqués ✓');
+ }}
+ disabled={simLoading}
+ flex
+ />
+ { clearTrophyOverrides(); showFeedback('Overrides réinitialisés ✓'); }}
+ disabled={simLoading}
+ flex
+ variant="dim"
+ />
+
+
+ {/* Accordéon — liste individuelle */}
+ setTrophyExpanded(v => !v)}
+ activeOpacity={0.75}
+ >
+
+ Gestion individuelle des trophées
+
+
+
+
+ {trophyExpanded && allTrophyCategories.map((cat) => {
+ const catTrophies = fullTrophyList.filter((t) => t.category === cat.id);
+ if (catTrophies.length === 0) return null;
+ return (
+
+ {cat.label}
+ {catTrophies.map((t) => (
+
+
+
+
+
+
+ {t.label}
+
+ {t.condition}
+
+ {t.isBackend ? (
+ // Trophée de compte (serveur) : pas d'override dev possible,
+ // affiche uniquement le statut réel.
+
+
+
+ ) : (
+
+ {!t.naturalUnlocked && trophyOverrides[t.id] === true && (
+ DEV
+ )}
+ setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)}
+ trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }}
+ thumbColor={t.unlocked ? t.color : '#888'}
+ style={styles.trophySwitch}
+ />
+
)}
- setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)}
- trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }}
- thumbColor={t.unlocked ? t.color : '#888'}
- style={styles.trophySwitch}
- />
-
- ))}
-
- );
- })}
+ ))}
+
+ );
+ })}
+
{/* ── NOTIFICATIONS ── */}
-
- Déclenche une notification de test dans 3 secondes. Passe l'app en arrière-plan.
-
- runNotifTest('orange')} disabled={simLoading} flex variant="orange" />
- runNotifTest('violet')} disabled={simLoading} flex variant="violet" />
-
+
+ Déclenche une notification de test dans 3 secondes. Passe l'app en arrière-plan.
+
+ runNotifTest('orange')} disabled={simLoading} flex variant="orange" />
+ runNotifTest('violet')} disabled={simLoading} flex variant="violet" />
+
+
- {/* ── RESET ── */}
-
-
- Décale les séances d'aujourd'hui à hier — relance le gain d'XP.
-
- Supprime uniquement les logs [DEBUG] — les vraies séances sont conservées.
+ {/* ── RESET / DANGER ZONE ── */}
+
+
+ Décale les séances d'aujourd'hui à hier, relance le gain d'XP.
+
+ Supprime uniquement les logs [DEBUG], les vraies séances sont conservées.
+
{simLoading && (
@@ -641,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
@@ -660,7 +1192,7 @@ export default function SettingsScreen({ navigation }) {
{chapter.title}
- {chapter.subtitle} · {chapter.steps.length} étapes
+ Chapitre {idx + 1} · {chapter.steps.length} étapes
@@ -676,7 +1208,7 @@ export default function SettingsScreen({ navigation }) {
{/* ─── Suppression définitive du compte ────────────────────────────── */}
setDeleteModal1(true)} activeOpacity={0.7}>
-
+
Supprimer le compte
@@ -692,7 +1224,7 @@ export default function SettingsScreen({ navigation }) {
-
+
Bienvenue à bord !
@@ -722,7 +1254,7 @@ export default function SettingsScreen({ navigation }) {
-
+
Êtes-vous sûr ?
@@ -748,7 +1280,7 @@ export default function SettingsScreen({ navigation }) {
-
+
Confirmation finale
@@ -781,6 +1313,37 @@ export default function SettingsScreen({ navigation }) {
+
+ setInfoModal(null)}
+ />
+
+ { setLogoutConfirmVisible(false); signOut(); }}
+ onCancel={() => setLogoutConfirmVisible(false)}
+ />
+
+ setClearDebugConfirmVisible(false)}
+ />
);
}
@@ -819,11 +1382,27 @@ function SegBtn({ label, active, onPress }) {
);
}
-function DevSectionTitle({ title }) {
+// Section repliable de la console God Mode — chaque nouvel outil se range
+// dans une section existante ou en ouvre une nouvelle, sans jamais allonger
+// un mur de boutons toujours visible. `defaultOpen` réservé aux sections les
+// plus consultées (Progression) ; toutes les autres démarrent repliées.
+function DevSection({ title, icon, badge, defaultOpen = false, children }) {
+ const [open, setOpen] = useState(defaultOpen);
return (
-
- {title}
-
+
+ setOpen((v) => !v)} activeOpacity={0.75}>
+
+
+ {title.toUpperCase()}
+ {badge && (
+
+ {badge}
+
+ )}
+
+
+
+ {open && {children}}
);
}
@@ -861,7 +1440,7 @@ function DevBtn({ label, onPress, disabled, variant = 'default', flex, fullWidth
// ─── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
- root: { flex: 1, backgroundColor: '#080910' },
+ root: { flex: 1, backgroundColor: Colors.bgAbyss },
scroll: { flex: 1 },
scrollContent: { paddingHorizontal: 16, paddingTop: 8 },
@@ -877,6 +1456,22 @@ const styles = StyleSheet.create({
valueText: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' },
segRow: { flexDirection: 'row', gap: 6 },
+
+ // ── Parrainage ──
+ referralRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
+ referralCode: {
+ color: Colors.gold, fontSize: 14, fontWeight: '800', letterSpacing: 1.2,
+ },
+ referralShareBtn: {
+ width: 32, height: 32, borderRadius: 10,
+ backgroundColor: 'rgba(254,116,57,0.12)',
+ borderWidth: 1, borderColor: 'rgba(254,116,57,0.30)',
+ justifyContent: 'center', alignItems: 'center',
+ },
+ referralHint: {
+ color: Colors.textMuted, fontSize: 11, lineHeight: 16,
+ marginTop: 8, marginHorizontal: 4,
+ },
seg: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, borderWidth: 1, borderColor: GRP_BDR, backgroundColor: 'rgba(255,255,255,0.04)' },
segActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
segText: { color: Colors.textSecondary, fontSize: 12, fontWeight: '700' },
@@ -887,10 +1482,15 @@ const styles = StyleSheet.create({
themeItemSel: { backgroundColor: 'rgba(255,255,255,0.07)', borderColor: 'rgba(255,255,255,0.25)' },
themeItemLocked: { opacity: 0.35 },
themeSwatch: { width: 32, height: 32, borderRadius: 16, marginBottom: 5 },
- themeCheck: { position: 'absolute', top: 6, right: 6, width: 14, height: 14, borderRadius: 7, backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: '#13131C' },
+ themeCheck: { position: 'absolute', top: 6, right: 6, width: 14, height: 14, borderRadius: 7, backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: Colors.bgDeep2 },
themeLockOverlay:{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
themeLabel: { color: Colors.textMuted, fontSize: 8, fontWeight: '600', textAlign: 'center' },
themeLabelLocked:{ color: Colors.textMuted },
+ themeSpecialHint: {
+ flexDirection: 'row', alignItems: 'flex-start', gap: 6,
+ paddingHorizontal: 12, paddingBottom: 12, paddingTop: 2,
+ },
+ themeSpecialHintTxt: { flex: 1, color: Colors.textMuted, fontSize: 10.5, lineHeight: 14 },
// ── God Mode console ───────────────────────────────────────────────────────
devConsole: { marginTop: 10, backgroundColor: GOLD_BG, borderRadius: 16, borderWidth: 1, borderColor: GOLD_BDR, padding: 16, gap: 12 },
@@ -906,9 +1506,30 @@ const styles = StyleSheet.create({
devStatValue: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' },
devStatLabel: { color: Colors.textMuted, fontSize: 9, fontWeight: '600', marginTop: 2, letterSpacing: 0.5 },
- devSectionRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 4 },
- devSectionTitle:{ color: 'rgba(255,215,0,0.6)', fontSize: 9, fontWeight: '800', letterSpacing: 1.4 },
- devSectionLine: { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: GOLD_BDR },
+ devSectionWrap: {
+ marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: GOLD_BDR,
+ backgroundColor: 'rgba(0,0,0,0.2)', overflow: 'hidden',
+ },
+ devSectionHeader: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
+ paddingHorizontal: 12, paddingVertical: 11,
+ },
+ devSectionHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 },
+ devSectionTitle:{ color: 'rgba(255,215,0,0.75)', fontSize: 11, fontWeight: '800', letterSpacing: 1.2 },
+ devSectionBadge: {
+ backgroundColor: 'rgba(254,116,57,0.18)', borderWidth: 1, borderColor: 'rgba(254,116,57,0.4)',
+ borderRadius: 6, paddingHorizontal: 6, paddingVertical: 1,
+ },
+ devSectionBadgeTxt: { color: Colors.primary, fontSize: 9, fontWeight: '800' },
+ devSectionBody: { paddingHorizontal: 12, paddingBottom: 14, gap: 10 },
+ devSubDivider: { height: StyleSheet.hairlineWidth, backgroundColor: GOLD_BDR, marginVertical: 2 },
+ testTagBox: {
+ backgroundColor: 'rgba(254,116,57,0.10)',
+ borderWidth: 1, borderColor: 'rgba(254,116,57,0.35)',
+ borderRadius: 10, paddingVertical: 10, alignItems: 'center',
+ },
+ testTagLabel: { color: 'rgba(255,215,0,0.6)', fontSize: 9.5, fontWeight: '800', letterSpacing: 1 },
+ testTagValue: { color: Colors.primary, fontSize: 17, fontWeight: '800', marginTop: 3 },
devInputRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
devInput: { flex: 1, height: 40, borderRadius: 10, borderWidth: 1, borderColor: GOLD_BDR, backgroundColor: 'rgba(0,0,0,0.35)', color: Colors.gold, fontSize: 15, fontWeight: '700', paddingHorizontal: 12 },
@@ -925,7 +1546,7 @@ const styles = StyleSheet.create({
devBtnTextDestructive: { color: Colors.error },
devBtnTextDim: { color: Colors.textMuted },
devBtnTextOrange: { color: '#FF6B00' },
- devBtnTextViolet: { color: '#8B5CF6' },
+ devBtnTextViolet: { color: Colors.rankViolet },
devBtnTextDisabled: { color: Colors.textMuted },
devHint: { color: 'rgba(255,215,0,0.40)', fontSize: 10, fontWeight: '500', lineHeight: 14, marginTop: -4 },
@@ -984,16 +1605,16 @@ const styles = StyleSheet.create({
// ── Welcome modal (fin tutoriel) ─────────────────────────────────────────────
welcomeCard: { borderColor: 'rgba(110,106,240,0.30)' },
welcomeIconWrap:{ width: 64, height: 64, borderRadius: 20, backgroundColor: 'rgba(110,106,240,0.12)', borderWidth: 1, borderColor: 'rgba(110,106,240,0.30)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 },
- welcomeBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: '#6E6AF0', justifyContent: 'center', marginBottom: 10, shadowColor: '#6E6AF0', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.40, shadowRadius: 12, elevation: 6 },
+ welcomeBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: Colors.secondaryAccent, justifyContent: 'center', marginBottom: 10, shadowColor: Colors.secondaryAccent, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.40, shadowRadius: 12, elevation: 6 },
welcomeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
// ── Delete Account button ────────────────────────────────────────────────────
deleteAccountBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 12, paddingVertical: 14, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(239,68,68,0.15)', backgroundColor: 'transparent' },
- deleteAccountText: { color: '#FF4D4D', fontSize: 13, fontWeight: '600', opacity: 0.75 },
+ deleteAccountText: { color: Colors.error, fontSize: 13, fontWeight: '600', opacity: 0.75 },
// ── Delete Account Modals ────────────────────────────────────────────────────
dmBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
- dmCard: { width: '100%', backgroundColor: '#13131C', borderRadius: 22, borderWidth: 1, borderColor: 'rgba(239,68,68,0.22)', padding: 28, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20 },
+ dmCard: { width: '100%', backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(239,68,68,0.22)', padding: 28, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20 },
dmCardFinal: { borderColor: 'rgba(220,38,38,0.35)' },
dmIconWrap: { width: 60, height: 60, borderRadius: 18, backgroundColor: 'rgba(239,68,68,0.10)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.25)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 },
dmIconFinalWrap:{ width: 60, height: 60, borderRadius: 18, backgroundColor: 'rgba(220,38,38,0.14)', borderWidth: 1, borderColor: 'rgba(220,38,38,0.35)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 },
@@ -1002,7 +1623,7 @@ const styles = StyleSheet.create({
dmKeepBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: Colors.primary, justifyContent: 'center', marginBottom: 10, shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6 },
dmKeepTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
dmContinueBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' },
- dmContinueTxt: { color: '#EF4444', fontSize: 14, fontWeight: '600' },
+ dmContinueTxt: { color: Colors.destructive, fontSize: 14, fontWeight: '600' },
dmDestroyBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: '#DC2626', justifyContent: 'center', marginBottom: 10, shadowColor: '#DC2626', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.45, shadowRadius: 12, elevation: 6 },
dmDestroyTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 },
dmBackBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' },
diff --git a/front/src/screens/Profile/TrophyRoomScreen.js b/front/src/screens/Profile/TrophyRoomScreen.js
index d64ea97..2bda6e5 100644
--- a/front/src/screens/Profile/TrophyRoomScreen.js
+++ b/front/src/screens/Profile/TrophyRoomScreen.js
@@ -17,10 +17,28 @@ import {
TROPHY_FILTER_TABS,
evaluateTrophies,
} from '../../data/trophyCatalog';
-import { useFeaturedTrophies, MAX_FEATURED } from '../../hooks/useFeaturedTrophies';
+import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies';
import { useFocusEffect } from '@react-navigation/native';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
+import { getAchievements } from '../../services/reward.service';
+import { normalizeAchievement } from '../../components/profile/AchievementShowcase';
+import { TrophyIcon } from '../../components/profile/TrophySlot';
+import { COLLECTION_CATEGORY, BACKEND_CATEGORY_MAP } from '../../data/backendTrophyCategories';
+
+// Masque un trophée "secret" tant qu'il n'est pas débloqué — même logique que
+// le masquage backend (buildAchievementsView) appliqué au profil d'un ami :
+// la surprise doit rester entière, y compris pour soi-même.
+function maskIfSecret(trophy) {
+ if (trophy.category !== 'secret' || trophy.unlocked) return trophy;
+ return {
+ ...trophy,
+ label: '???',
+ condition: 'Trophée secret',
+ epicDesc: 'Ce trophée est encore secret. Continuez à vous entraîner pour le découvrir.',
+ icon: 'help-circle',
+ };
+}
// ─── Glow config per tier ─────────────────────────────────────────────────────
@@ -34,7 +52,7 @@ const TIER_GLOW = {
// ─── UltimateTile ─────────────────────────────────────────────────────────────
-function UltimateTile({ unlocked, onPress }) {
+function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) {
const scale = useRef(new Animated.Value(1)).current;
const gleamX = useRef(new Animated.Value(-140)).current;
const glow = useRef(new Animated.Value(0)).current;
@@ -82,7 +100,7 @@ function UltimateTile({ unlocked, onPress }) {
styles.ultimateTile,
unlocked && {
borderColor: 'rgba(255,215,0,0.6)',
- borderTopColor: '#FFD700',
+ borderTopColor: Colors.gold,
},
{ transform: [{ scale }] },
]}>
@@ -100,7 +118,7 @@ function UltimateTile({ unlocked, onPress }) {
{/* Icon */}
{unlocked ? (
-
+
{ULTIMATE_TROPHY.label}
- {unlocked ? 'Collection complète — Vous régnez.' : ULTIMATE_TROPHY.condition}
+ {unlocked ? 'Collection complète - Vous régnez.' : ULTIMATE_TROPHY.condition}
{unlocked && (
-
+
DIAMOND · ULTIME
)}
@@ -141,7 +159,7 @@ function UltimateTile({ unlocked, onPress }) {
{!unlocked && (
- {TROPHY_CATALOG.length - 0} trophées requis · Progressez pour débloquer
+ {unlockedCount}/{totalCount} trophées débloqués · Progressez pour tous les débloquer
)}
@@ -157,6 +175,7 @@ export default function TrophyRoomScreen({ navigation }) {
const { toggleFeatured, isFeatured } = useFeaturedTrophies();
const [selected, setSelected] = useState(null);
const [activeFilter, setFilter] = useState('all');
+ const [backendAchievements, setBackendAchievements] = useState([]);
const { level } = useMemo(() => xpToLevel(totalXP), [totalXP]);
const totalSessions = logs.length;
@@ -166,16 +185,57 @@ export default function TrophyRoomScreen({ navigation }) {
[level, totalSessions, logs, totalXP, trophyOverrides],
);
- const unlockedCount = evaluated.filter((t) => t.unlocked).length;
- const ultimateUnlocked = evaluated.every((t) => t.unlocked);
+ // Trophées backend V2 (anniversaire, collection d'objets, coffres, social) —
+ // chargés à part car ils dépendent du compte serveur, pas des logs locaux.
+ // Échec silencieux : la Salle des Trophées reste utilisable hors-ligne avec
+ // uniquement le catalogue local.
+ useFocusEffect(
+ useCallback(() => {
+ let cancelled = false;
+ getAchievements()
+ .then((res) => { if (!cancelled) setBackendAchievements(res.achievements ?? []); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, []),
+ );
+
+ const normalizedBackend = useMemo(
+ () => backendAchievements.map((a) => ({
+ ...normalizeAchievement(a),
+ category: BACKEND_CATEGORY_MAP[a.category] || 'collection',
+ isBackend: true,
+ })),
+ [backendAchievements],
+ );
+
+ // Vue combinée (catalogue local + backend) pour l'affichage par catégorie.
+ // Les trophées "secret" verrouillés sont masqués (mêmes égards que le
+ // profil d'un ami : on ne se spoile pas soi-même la surprise).
+ const fullEvaluated = useMemo(
+ () => [...evaluated.map(maskIfSecret), ...normalizedBackend],
+ [evaluated, normalizedBackend],
+ );
+
+ const unlockedCount = fullEvaluated.filter((t) => t.unlocked).length;
+ const totalCatalogSize = TROPHY_CATALOG.length + backendAchievements.length;
+ // Le Trophée Ultime exige DÉSORMAIS la collection complète (local + backend) —
+ // tant que les trophées backend n'ont pas fini de charger, on le laisse
+ // verrouillé plutôt que de l'unlock prématurément sur le seul catalogue local.
+ const ultimateUnlocked = backendAchievements.length > 0 && fullEvaluated.every((t) => t.unlocked);
const ultimateTrophy = useMemo(() => ({ ...ULTIMATE_TROPHY, unlocked: ultimateUnlocked }), [ultimateUnlocked]);
+ const allCategories = useMemo(() => [...TROPHY_CATEGORIES, COLLECTION_CATEGORY], []);
+
+ // TROPHY_FILTER_TABS couvre déjà nativement "collection" via son propre
+ // onglet dédié ("Coffres & Raretés") — plus besoin de patch ad-hoc ici.
+ const filterTabs = TROPHY_FILTER_TABS;
+
const visibleCategories = useMemo(() => {
- if (activeFilter === 'all') return TROPHY_CATEGORIES;
- const tab = TROPHY_FILTER_TABS.find(t => t.id === activeFilter);
- if (!tab || !tab.categories) return TROPHY_CATEGORIES;
- return TROPHY_CATEGORIES.filter(c => tab.categories.includes(c.id));
- }, [activeFilter]);
+ if (activeFilter === 'all') return allCategories;
+ const tab = filterTabs.find(t => t.id === activeFilter);
+ if (!tab || !tab.categories) return allCategories;
+ return allCategories.filter(c => tab.categories.includes(c.id));
+ }, [activeFilter, allCategories, filterTabs]);
// ─── Tutorial ───────────────────────────────────────────────────────────
const {
@@ -190,9 +250,9 @@ export default function TrophyRoomScreen({ navigation }) {
const firstTrophyId = useMemo(() => {
if (visibleCategories.length === 0) return null;
const firstCat = visibleCategories[0];
- const catTrophies = evaluated.filter((t) => t.category === firstCat.id);
+ const catTrophies = fullEvaluated.filter((t) => t.category === firstCat.id);
return catTrophies.length > 0 ? catTrophies[0].id : null;
- }, [visibleCategories, evaluated]);
+ }, [visibleCategories, fullEvaluated]);
const scrollRef = useRef(null);
@@ -226,26 +286,32 @@ export default function TrophyRoomScreen({ navigation }) {
Salle des Trophées
- {unlockedCount}/{TROPHY_CATALOG.length} débloqués
+ {unlockedCount}/{totalCatalogSize} débloqués
- {/* Filter tabs */}
-
- {TROPHY_FILTER_TABS.map((tab) => {
- const active = activeFilter === tab.id;
- return (
- setFilter(tab.id)}
- activeOpacity={0.75}
- >
- {tab.label}
-
- );
- })}
+ {/* Filter tabs — scroll horizontal (6 onglets ne tiennent plus à plat) */}
+
+
+ {filterTabs.map((tab) => {
+ const active = activeFilter === tab.id;
+ return (
+ setFilter(tab.id)}
+ activeOpacity={0.75}
+ >
+ {tab.label}
+
+ );
+ })}
+
-
+
- Trophée Ultime
+ Trophée Ultime
{ultimateUnlocked ? '1/1' : '0/1'}
- setSelected(ultimateTrophy)} />
+ setSelected(ultimateTrophy)} />
)}
{/* ── Catégories standard ── */}
{visibleCategories.map((cat) => {
- const catTrophies = evaluated.filter((t) => t.category === cat.id);
+ const catTrophies = fullEvaluated.filter((t) => t.category === cat.id);
if (catTrophies.length === 0) return null;
const catUnlocked = catTrophies.filter((t) => t.unlocked).length;
return (
@@ -345,7 +411,7 @@ function GalleryTile({ trophy, onPress, tutorialRef, tutorialOnLayout }) {
]}>
{unlocked ? (
-
+
) : (
@@ -367,7 +433,7 @@ function GalleryTile({ trophy, onPress, tutorialRef, tutorialOnLayout }) {
// ─── TrophyDetailModal ────────────────────────────────────────────────────────
function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatured }) {
- const { icon, label, condition, epicDesc, color, gradientColors, unlocked, tier } = trophy;
+ const { icon, label, condition, epicDesc, color, gradientColors, unlocked, tier, isBackend } = trophy;
const scale = useRef(new Animated.Value(0.88)).current;
const opacity = useRef(new Animated.Value(0)).current;
@@ -388,7 +454,7 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur
{unlocked
- ?
+ ?
: }
@@ -401,14 +467,14 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur
- {unlocked ? `${tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : ''} — Débloqué` : 'Verrouillé'}
+ {unlocked ? `${tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : ''} - Débloqué` : 'Verrouillé'}
- {unlocked ? epicDesc : "Accomplissez encore — ce trophée attend le guerrier que vous deviendrez."}
+ {unlocked ? epicDesc : "Accomplissez encore - ce trophée attend le guerrier que vous deviendrez."}
- {unlocked && (
+ {unlocked && !isBackend && (
{ onToggleFeatured?.(); onClose(); }}
@@ -420,6 +486,12 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur
)}
+ {unlocked && isBackend && (
+
+
+ Trophée de compte
+
+ )}
Fermer
@@ -442,8 +514,8 @@ const styles = StyleSheet.create({
headerTitle: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800' },
headerSub: { color: Colors.textMuted, fontSize: 11, fontWeight: '600', marginTop: 2 },
- filterRow: { flexDirection: 'row', marginHorizontal: 16, paddingBottom: 12, gap: 8 },
- filterTab: { flex: 1, paddingVertical: 7, borderRadius: 10, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)' },
+ filterRow: { flexDirection: 'row', paddingHorizontal: 16, paddingBottom: 12, gap: 8 },
+ filterTab: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 10, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)' },
filterTabActive: { backgroundColor: 'rgba(254,116,57,0.14)', borderColor: Colors.primary + '50' },
filterLabel: { fontSize: 11, fontWeight: '700', color: Colors.textMuted, letterSpacing: 0.5 },
filterLabelActive: { color: Colors.primary },
@@ -499,7 +571,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8, paddingVertical: 4, alignSelf: 'flex-start',
borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)',
},
- ultimateBadgeText: { color: '#FFD700', fontSize: 9, fontWeight: '900', letterSpacing: 1 },
+ ultimateBadgeText: { color: Colors.gold, fontSize: 9, fontWeight: '900', letterSpacing: 1 },
ultimateHint: { color: 'rgba(255,255,255,0.2)', fontSize: 10, fontWeight: '500', textAlign: 'center', marginTop: 12 },
// ── Tile ──────────────────────────────────────────────────────────────────
diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js
new file mode 100644
index 0000000..9eb8ab4
--- /dev/null
+++ b/front/src/screens/Social/FriendProfileScreen.js
@@ -0,0 +1,332 @@
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import {
+ View, Text, StyleSheet, ScrollView, TouchableOpacity,
+ StatusBar, ActivityIndicator, Animated,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { LinearGradient } from 'expo-linear-gradient';
+import { Ionicons } from '@expo/vector-icons';
+import { Colors } from '../../constants/theme';
+import { useToast } from '../../context/ToastContext';
+import { getRank, xpToLevel } from '../../services';
+import { getFriendProfile, getMyGroup, shakeMember } from '../../services';
+import { RARITY_META } from '../../services';
+
+import HeroLevelCard from '../../components/profile/HeroLevelCard';
+import StreakBadge from '../../components/profile/StreakBadge';
+import EmberParticles from '../../components/profile/EmberParticles';
+import AchievementShowcase from '../../components/profile/AchievementShowcase';
+import FriendShowcaseGrid from '../../components/profile/FriendShowcaseGrid';
+import PersonalRecordsList from '../../components/profile/PersonalRecordsList';
+import { resolveExerciseMeta } from '../../data/majorExercises';
+import { getFriendshipTitle } from '../../data/friendshipTitles';
+
+// ─── FriendProfileScreen ──────────────────────────────────────────────────────
+// Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre
+// ProfileScreen — même hero card (avec le cadre équipé de l'ami), même jauge
+// d'XP/niveau/rang, mêmes stats globales, et la vitrine complète des trophées
+// backend V2. Aucune action personnelle (Sac, Modifier le profil) : uniquement
+// des actions sociales légitimes (Secouer si coéquipier, statut d'amitié).
+//
+// Le backend refuse (403) si la relation n'est pas acceptée.
+
+function buildBgGradient(isGod, isLegend, isElite) {
+ if (isGod) return ['#0A0800', '#100D02', Colors.bgAbyss, '#08080E'];
+ if (isLegend) return [Colors.bgAbyss, '#0C0816', '#0D0A1A', Colors.bgAbyss];
+ if (isElite) return [Colors.bgAbyss, '#0A0A18', '#0D0D1C', Colors.bgAbyss];
+ return [Colors.bgAbyss, '#09090F', Colors.bgAbyss];
+}
+
+export default function FriendProfileScreen({ route, navigation }) {
+ const { friendId, pseudo } = route.params ?? {};
+ const { showToast } = useToast();
+ const insets = useSafeAreaInsets();
+
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [sharedGroup, setSharedGroup] = useState(null); // groupe où l'ami et moi sommes tous deux membres
+ const [shaking, setShaking] = useState(false);
+
+ const opacity = useRef(new Animated.Value(0)).current;
+
+ const load = useCallback(async () => {
+ try {
+ const [profileRes, groupRes] = await Promise.all([
+ getFriendProfile(friendId),
+ getMyGroup().catch(() => null),
+ ]);
+ setProfile(profileRes.profile);
+
+ const group = groupRes?.group;
+ const isSharedGroup = group?.members?.some((m) => m._id === friendId);
+ setSharedGroup(isSharedGroup ? group : null);
+
+ Animated.timing(opacity, { toValue: 1, duration: 280, useNativeDriver: true }).start();
+ } catch (error) {
+ if (!error.isSessionExpired) {
+ showToast(error.data?.message || 'Profil inaccessible.', 'error');
+ }
+ navigation.goBack();
+ } finally {
+ setLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [friendId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const alreadyShakenToday = (sharedGroup?.shakenTodayByMe ?? []).includes(friendId);
+
+ const handleShake = async () => {
+ if (shaking || !sharedGroup || alreadyShakenToday) return;
+ setShaking(true);
+ try {
+ const res = await shakeMember(sharedGroup._id, friendId);
+ showToast(res.message || `${pseudo} a été secoué !`, 'success');
+ await load();
+ } catch (error) {
+ if (!error.isSessionExpired) showToast(error.data?.message || 'Action impossible.', 'error');
+ } finally {
+ setShaking(false);
+ }
+ };
+
+ // Records mis en avant par l'ami — même forme que PersonalRecordsList
+ // utilisée sur son propre profil (ProfileScreen.js), pour un rendu
+ // identique plutôt qu'une liste texte séparée.
+ const records = useMemo(() => {
+ if (!profile) return [];
+ return (profile.records || []).map((r) => {
+ const meta = resolveExerciseMeta(r.exercice);
+ return {
+ name: r.exercice,
+ group: meta.group,
+ icon: meta.icon,
+ prWeight: r.maxPoids,
+ prEstimate1RM: Math.round(r.maxPoids * (1 + r.maxReps / 30) * 10) / 10,
+ hasData: true,
+ subtitle: `${r.maxReps} reps max`,
+ };
+ });
+ }, [profile]);
+
+ const level = useMemo(() => xpToLevel(profile?.user?.xp ?? 0).level, [profile]);
+ const rank = useMemo(() => getRank(level), [level]);
+ const friendshipTitle = useMemo(() => getFriendshipTitle(profile?.friendshipLevel), [profile]);
+ const isElite = level >= 91;
+ const isLegend = level >= 171;
+ const isGod = level >= 200;
+ const bgColors = buildBgGradient(isGod, isLegend, isElite);
+ const topPad = insets.top + 16;
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!profile) return null;
+
+ const equippedFrame = profile.user.equippedFrame || { shapeId: 'circle', colorId: 'none' };
+
+ // Vitrine : IDs mis en avant résolus en entrées complètes du catalogue unifié
+ const showcasedEntries = (profile.showcasedAchievements || [])
+ .map((id) => (profile.achievements || []).find((a) => a.id === id))
+ .filter(Boolean);
+
+ return (
+
+
+
+
+ navigation.goBack()}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ activeOpacity={0.7}
+ >
+
+
+
+
+
+
+ {/* ── Hero Level Card (cadre équipé de l'ami) ── */}
+
+
+
+
+
+ {/* ── Streak ── */}
+ {profile.stats.streak > 0 && (
+
+
+
+ )}
+
+ {/* ── Vitrine de l'ami : ses trophées mis en avant (mêmes TrophySlot que le profil) ── */}
+
+
+ {/* ── Statut d'amitié ── */}
+
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+
+ Niveau d'amitié {profile.friendshipLevel}/5
+ {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''}
+
+ {friendshipTitle.label}
+ {profile.friendshipXp} XP d'amitié
+
+
+ {/* ── Action sociale : Secouer (uniquement si coéquipier de streak) ── */}
+ {sharedGroup && (
+
+
+ {shaking
+ ?
+ : }
+
+
+ Coéquipier de streak
+
+ {alreadyShakenToday
+ ? `Tu as déjà secoué ${profile.user.pseudo} aujourd'hui`
+ : `Secoue ${profile.user.pseudo} s'il n'a pas encore fait sa séance`}
+
+
+ {!alreadyShakenToday && }
+
+ )}
+
+ {/* ── Vitrine des trophées ── */}
+
+
+ {/* ── Records personnels (même composant que sur son propre profil) ── */}
+
+
+
+
+
+
+ );
+}
+
+// ─── Sub-components (miroir de ProfileScreen.js) ──────────────────────────────
+
+function Section({ title, children }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function GlassCard({ children }) {
+ return {children};
+}
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: Colors.bgAbyss },
+ scroll: { flex: 1 },
+ scrollContent: { paddingHorizontal: 20, paddingBottom: 40 },
+ centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
+
+ backBtn: { position: 'absolute', left: 20, zIndex: 10 },
+
+ heroWrapper: { position: 'relative' },
+ streakWrap: { marginTop: 10 },
+
+ friendshipCard: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(139,92,246,0.07)',
+ borderWidth: 1, borderColor: 'rgba(139,92,246,0.28)',
+ borderRadius: 16, padding: 16, marginTop: 14,
+ },
+ friendshipHearts: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
+ friendshipLevel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' },
+ friendshipTitle: { color: Colors.primary, fontSize: 12, fontWeight: '700', marginTop: 4 },
+ friendshipXp: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2 },
+
+ shakeBanner: {
+ flexDirection: 'row', alignItems: 'center', gap: 12,
+ marginTop: 12,
+ backgroundColor: 'rgba(255,77,77,0.07)',
+ borderWidth: 1, borderColor: 'rgba(255,77,77,0.28)',
+ borderRadius: 14, paddingVertical: 12, paddingHorizontal: 14,
+ },
+ shakeBannerDisabled: {
+ backgroundColor: 'rgba(255,255,255,0.03)',
+ borderColor: Colors.borderSubtle,
+ },
+ shakeIconBox: {
+ width: 36, height: 36, borderRadius: 11,
+ backgroundColor: 'rgba(255,77,77,0.12)',
+ justifyContent: 'center', alignItems: 'center',
+ },
+ shakeTitle: { color: Colors.textPrimary, fontSize: 13, fontWeight: '700' },
+ shakeSub: { color: Colors.textMuted, fontSize: 11, marginTop: 1 },
+
+ section: { marginTop: 26 },
+ sectionTitle: {
+ color: Colors.textPrimary, fontSize: 11, fontWeight: '800',
+ letterSpacing: 1.4, textTransform: 'uppercase', marginBottom: 12,
+ },
+ glassCard: {
+ backgroundColor: Colors.glassBg,
+ borderWidth: 1, borderColor: Colors.glassBorder,
+ borderRadius: 18, padding: 14, overflow: 'hidden',
+ },
+
+});
diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js
new file mode 100644
index 0000000..60fbe5b
--- /dev/null
+++ b/front/src/screens/Social/SocialScreen.js
@@ -0,0 +1,1186 @@
+import React, { useState, useCallback, useRef, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, ScrollView, TouchableOpacity, TextInput,
+ StatusBar, ActivityIndicator, Animated, RefreshControl,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect } from '@react-navigation/native';
+import { Colors } from '../../constants/theme';
+import { useToast } from '../../context/ToastContext';
+import { useUser } from '../../context/UserContext';
+import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
+import { ConfirmModal } from '../../components/common';
+import FriendshipLevelUpModal from '../../components/social/FriendshipLevelUpModal';
+import FriendPreviewModal from '../../components/social/FriendPreviewModal';
+import AddFriendModal from '../../components/social/AddFriendModal';
+import {
+ searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest,
+ cancelFriendRequest, removeFriend,
+ getFriendsList, getPendingRequests, getLeaderboard, getExerciseLeaderboard,
+ getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup,
+} from '../../services';
+import { MAJOR_EXERCISES } from '../../data/majorExercises';
+import { getFriendshipTitle } from '../../data/friendshipTitles';
+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 🥇🥈🥉.
+const MEDAL_COLORS = { 1: Colors.gold, 2: '#C0C0C0', 3: '#CD7F32' };
+
+function MedalBadge({ position, size = 16 }) {
+ const color = MEDAL_COLORS[position];
+ if (!color) return null;
+ return ;
+}
+
+const SEGMENTS = [
+ { key: 'friends', label: 'Amis', icon: 'people' },
+ { key: 'leaderboard', label: 'Classement', icon: 'podium' },
+ { key: 'group', label: 'Groupe', icon: 'flame' },
+];
+
+// ─── SocialScreen ─────────────────────────────────────────────────────────────
+// Hub social (Briques III & IV) : amis + demandes + recherche, classement XP,
+// groupe de streak avec bouton Secouer. Chaque segment a ses entrées animées.
+
+export default function SocialScreen({ navigation }) {
+ const { showToast } = useToast();
+ const { user, refetch: refetchUser } = useUser();
+ 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([]);
+ const [sentRequests, setSentRequests] = useState([]);
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [group, setGroup] = useState(null);
+ const [groupInvites, setGroupInvites] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ // ── Ajout d'ami par tag exact "Pseudo#1234" (Section III) ──
+ // Point d'entrée unique : bouton "+ Ajouter un ami" → AddFriendModal
+ // (pseudo + # séparés, affiche aussi mon propre tag) → un résultat trouvé
+ // ferme AddFriendModal et ouvre la carte Preview (FriendPreviewModal).
+ const [addFriendVisible, setAddFriendVisible] = useState(false);
+ const [searching, setSearching] = useState(false);
+ const [searchError, setSearchError] = useState('');
+ const [previewResult, setPreviewResult] = useState(null);
+
+ // ── Confirmation quitter le groupe ──
+ const [leaveConfirmVisible, setLeaveConfirmVisible] = useState(false);
+
+ // ── Confirmation retrait d'un ami ──
+ const [removeFriendTarget, setRemoveFriendTarget] = useState(null); // { friendshipId, pseudo }
+
+ // ── Célébration montée de niveau d'amitié ──────────────────────────────────
+ // Comparaison du niveau d'amitié de chaque ami entre deux loadAll() : toute
+ // hausse détectée (déclenchée par une validation de streak de groupe) est
+ // mise en file et célébrée une par une. Le tout premier chargement mémorise
+ // sans célébrer (évite un faux déclenchement au démarrage — même garde que
+ // LevelUpCelebration.js).
+ const previousFriendshipLevels = useRef(new Map());
+ const [levelUpQueue, setLevelUpQueue] = useState([]);
+ const [activeLevelUp, setActiveLevelUp] = useState(null);
+
+ // ── Popup "objet Unique à réclamer" (streak de groupe 30j à 5 membres) ────
+ const [bloodSangUnlockedVisible, setBloodSangUnlockedVisible] = useState(false);
+
+ const loadAll = useCallback(async () => {
+ try {
+ const [friendsRes, pendingRes, boardRes, groupRes] = await Promise.all([
+ getFriendsList(), getPendingRequests(), getLeaderboard(), getMyGroup(),
+ ]);
+ const incomingFriends = friendsRes.friends ?? [];
+ const newlyLeveledUp = [];
+ for (const f of incomingFriends) {
+ const prevLevel = previousFriendshipLevels.current.get(f.friendshipId);
+ if (prevLevel !== undefined && f.friendshipLevel > prevLevel) {
+ newlyLeveledUp.push({ pseudo: f.user.pseudo, level: f.friendshipLevel });
+ }
+ previousFriendshipLevels.current.set(f.friendshipId, f.friendshipLevel);
+ }
+ if (newlyLeveledUp.length > 0) {
+ setLevelUpQueue((q) => [...q, ...newlyLeveledUp]);
+ }
+
+ setFriends(incomingFriends);
+ setPending(pendingRes.requests ?? []);
+ setSentRequests(pendingRes.sent ?? []);
+ setLeaderboard(boardRes.leaderboard ?? []);
+ setGroup(groupRes.group ?? null);
+ setGroupInvites(groupRes.invites ?? []);
+ } catch (error) {
+ if (!error.isSessionExpired) {
+ showToast('Impossible de charger le social. Vérifie ta connexion.', 'error');
+ }
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, [showToast]);
+
+ // Défile la file un item à la fois — jamais deux modales superposées.
+ useEffect(() => {
+ if (!activeLevelUp && levelUpQueue.length > 0) {
+ setActiveLevelUp(levelUpQueue[0]);
+ setLevelUpQueue((q) => q.slice(1));
+ }
+ }, [activeLevelUp, levelUpQueue]);
+
+ 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 à
+ // partir des 2 champs séparés (pseudo + # à 4 chiffres) — jamais au fil de
+ // la frappe, pour éviter d'ajouter la mauvaise personne.
+ const onSearchTag = async (fullTag) => {
+ setSearchError('');
+ setSearching(true);
+ try {
+ const res = await searchUsers(fullTag);
+ const found = (res.results ?? [])[0];
+ if (found) {
+ setPreviewResult(found);
+ setAddFriendVisible(false);
+ } else {
+ setSearchError('Aucun athlète ne correspond à ce tag.');
+ }
+ } catch (error) {
+ setSearchError(error?.data?.message || 'Recherche impossible.');
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ // ── Actions amis ──
+ const doAction = async (fn, successMsg) => {
+ try {
+ await fn();
+ if (successMsg) showToast(successMsg, 'success');
+ loadAll();
+ } catch (error) {
+ if (error.isSessionExpired) return;
+ showToast(error.data?.message || 'Action impossible.', 'error');
+ }
+ };
+
+ return (
+
+
+
+ Social
+
+ {/* ── Segments ── */}
+
+ {SEGMENTS.map((s) => {
+ const active = segment === s.key;
+ const badge = s.key === 'friends' && pending.length > 0 ? pending.length
+ : s.key === 'group' && groupInvites.length > 0 ? groupInvites.length
+ : null;
+ return (
+ setSegment(s.key)}
+ activeOpacity={0.8}
+ >
+
+ {s.label}
+ {badge != null && (
+ {badge}
+ )}
+
+ );
+ })}
+
+
+ {loading ? (
+
+ ) : (
+ }
+ keyboardShouldPersistTaps="handled"
+ >
+ {segment === 'friends' && (
+ setAddFriendVisible(true)}
+ onAccept={(id) => doAction(() => acceptFriendRequest(id), 'Vous êtes maintenant amis !')}
+ onDecline={(id) => doAction(() => declineFriendRequest(id))}
+ onCancelSent={(id) => doAction(() => cancelFriendRequest(id), 'Demande annulée.')}
+ onRemoveFriend={(friendshipId, pseudo) => setRemoveFriendTarget({ friendshipId, pseudo })}
+ onOpenProfile={(friend, friendshipLevel) =>
+ navigation.navigate('FriendProfile', { friendId: friend._id, pseudo: friend.pseudo, friendshipLevel })}
+ />
+ )}
+
+ {segment === 'leaderboard' && }
+
+ {segment === 'group' && (
+ doAction(() => inviteToGroup(ids, name), 'Demande de Streak de Groupe envoyée')}
+ onRespond={(groupId, accept) =>
+ doAction(() => respondToGroupInvite(groupId, accept), accept ? 'Bienvenue dans le groupe !' : null)}
+ onShake={(groupId, memberId, pseudo) =>
+ doAction(() => shakeMember(groupId, memberId), `${pseudo} a été secoué !`)}
+ onCheckStreak={async (groupId) => {
+ try {
+ const res = await checkGroupStreak(groupId);
+ if (res.allValidated) {
+ showToast(`Streak jour ${res.currentStreak} ! +${res.groupBonus?.bonusXp ?? 0} XP (x${res.groupBonus?.multiplier ?? 1})`, 'success');
+ // Le bonus XP peut faire franchir un palier de niveau, refetch
+ // le profil global pour que LevelUpCelebration le détecte,
+ // et pousse le même gain dans les logs locaux pour que le
+ // Profil (calculé localement) se mette à jour immédiatement.
+ refetchUser();
+ if (res.groupBonus?.bonusXp > 0) {
+ addBonusXp('Bonus de groupe', res.groupBonus.bonusXp);
+ }
+ if (res.bloodSangUnlocked) setBloodSangUnlockedVisible(true);
+ } else if (res.alreadyValidated) {
+ showToast('Déjà validée aujourd\'hui', 'success');
+ } else {
+ showToast('Tous les membres n\'ont pas encore validé leur séance.', 'error');
+ }
+ loadAll();
+ } catch (error) {
+ if (!error.isSessionExpired) showToast(error.data?.message || 'Erreur.', 'error');
+ }
+ }}
+ onLeaveGroup={() => setLeaveConfirmVisible(true)}
+ />
+ )}
+
+
+
+ )}
+
+ {
+ setLeaveConfirmVisible(false);
+ doAction(() => leaveGroup(), 'Vous avez quitté le groupe.');
+ }}
+ onCancel={() => setLeaveConfirmVisible(false)}
+ />
+
+ {
+ const target = removeFriendTarget;
+ setRemoveFriendTarget(null);
+ doAction(() => removeFriend(target.friendshipId), `${target.pseudo} a été retiré de tes amis.`);
+ }}
+ onCancel={() => setRemoveFriendTarget(null)}
+ />
+
+ {
+ setBloodSangUnlockedVisible(false);
+ navigation.navigate('ProfileTab', { screen: 'Inventory' });
+ }}
+ onCancel={() => setBloodSangUnlockedVisible(false)}
+ />
+
+ setActiveLevelUp(null)}
+ />
+
+ { setAddFriendVisible(false); setSearchError(''); }}
+ />
+
+ {
+ await doAction(() => sendFriendRequest(previewResult.user._id), 'Invitation envoyée');
+ setPreviewResult((prev) => prev && { ...prev, relationStatus: 'pending_sent' });
+ }}
+ onClose={() => setPreviewResult(null)}
+ />
+
+ {activeChapterId === 'social' && (
+
+ )}
+
+ );
+}
+
+// ═══ Segment Amis ═════════════════════════════════════════════════════════════
+
+function FriendsSegment({
+ friends, pending, sentRequests, onOpenAddFriend, onAccept, onDecline, onCancelSent, onRemoveFriend, onOpenProfile,
+}) {
+ return (
+ <>
+ {/* ── Point d'entrée unique pour ajouter un ami (Section III) ── */}
+
+
+ Ajouter un nouvel ami
+
+
+ {/* ── Demandes reçues ── */}
+ {pending.length > 0 && (
+ <>
+ DEMANDES REÇUES
+ {pending.map((req, i) => (
+
+
+ onAccept(req._id)} />
+ onDecline(req._id)} />
+
+
+ ))}
+ >
+ )}
+
+ {/* ── Demandes envoyées, en attente de réponse ── */}
+ {(sentRequests ?? []).length > 0 && (
+ <>
+ DEMANDES ENVOYÉES
+ {sentRequests.map((req, i) => (
+
+
+
+
+ En attente
+
+ onCancelSent(req._id)} />
+
+
+ ))}
+ >
+ )}
+
+ {/* ── Mes amis ── */}
+ MES AMIS ({friends.length})
+ {friends.length === 0 ? (
+
+
+ Pas encore d'amis.{'\n'}Cherche un pseudo ci-dessus pour commencer !
+
+ ) : friends.map((f, i) => (
+
+ onOpenProfile(f.user, f.friendshipLevel)}>
+
+
+ onRemoveFriend(f.friendshipId, f.user.pseudo)}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ style={{ marginLeft: 6 }}
+ >
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
+
+// ═══ Segment Classement ═══════════════════════════════════════════════════════
+
+const PODIUM_COLORS = [Colors.gold, '#C0C0C0', '#CD7F32'];
+
+function LeaderboardSegment({ leaderboard }) {
+ const [mode, setMode] = useState('xp'); // 'xp' | 'records'
+
+ return (
+ <>
+ {/* ── Bascule XP / Records ── */}
+
+ setMode('xp')}
+ activeOpacity={0.8}
+ >
+
+ XP
+
+ setMode('records')}
+ activeOpacity={0.8}
+ >
+
+ Records
+
+
+
+ {mode === 'xp' ? : }
+ >
+ );
+}
+
+function XpLeaderboard({ leaderboard }) {
+ const podium = leaderboard.slice(0, 3);
+ const rest = leaderboard.slice(3);
+
+ return (
+ <>
+ {leaderboard.length < 2 ? (
+
+
+ Ajoute des amis pour lancer la compétition !
+
+ ) : (
+ <>
+ {/* ── Podium ── */}
+
+ {[1, 0, 2].map((idx) => {
+ const entry = podium[idx];
+ if (!entry) return ;
+ return (
+
+ );
+ })}
+
+
+ {/* ── Reste du classement ── */}
+ {rest.map((entry, i) => (
+
+
+ #{entry.position}
+
+ {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''}
+
+ {entry.user.xp} XP
+
+
+ ))}
+ >
+ )}
+ >
+ );
+}
+
+// ── Classement par exercice (records du réseau d'amis) ─────────────────────────
+
+function RecordsLeaderboard() {
+ const [exercise, setExercise] = useState(MAJOR_EXERCISES[0].name);
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [pickerVisible, setPickerVisible] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ getExerciseLeaderboard(exercise)
+ .then((res) => { if (!cancelled) setRows(res.leaderboard ?? []); })
+ .catch(() => { if (!cancelled) setRows([]); })
+ .finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [exercise]);
+
+ return (
+ <>
+ {/* ── Suggestions rapides + accès au catalogue complet (~100+ exercices) ── */}
+
+ setPickerVisible(true)}
+ activeOpacity={0.8}
+ >
+
+ Tous les exercices
+
+ {MAJOR_EXERCISES.map((exo) => {
+ const active = exo.name === exercise;
+ return (
+ setExercise(exo.name)}
+ activeOpacity={0.8}
+ >
+ {exo.name}
+
+ );
+ })}
+
+
+ setPickerVisible(false)}
+ />
+
+ {loading ? (
+
+ ) : rows.length === 0 ? (
+
+
+
+ Aucun record sur cet exercice dans ton réseau.{'\n'}Sois le premier à poser la barre !
+
+
+ ) : rows.map((entry, i) => (
+
+
+
+ {entry.position <= 3
+ ?
+ : #{entry.position}}
+
+
+ {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''}
+
+ {entry.maxPoids} kg
+ × {entry.maxReps}
+
+
+ ))}
+ >
+ );
+}
+
+function PodiumColumn({ entry, height, color, delay }) {
+ const grow = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.spring(grow, { toValue: 1, friction: 6, tension: 50, delay, useNativeDriver: true }).start();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {entry.user.pseudo}
+ {entry.user.xp} XP
+
+
+
+
+
+
+ );
+}
+
+// ═══ Segment Groupe ═══════════════════════════════════════════════════════════
+
+function GroupSegment({ group, myId, invites, friends, onInvite, onRespond, onShake, onCheckStreak, onLeaveGroup }) {
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [groupName, setGroupName] = useState('');
+
+ const toggle = (id) =>
+ setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
+
+ return (
+ <>
+ {/* ── Invitations reçues ── */}
+ {invites.map((inv) => (
+
+
+
+
+ Demande de Streak de Groupe : {inv.name || 'Sans nom'}
+
+
+
+ onRespond(inv._id, true)} />
+ onRespond(inv._id, false)} />
+
+
+ ))}
+
+ {group ? (
+
+ ) : (
+ <>
+ CRÉER UN GROUPE DE STREAK (MAX 5)
+
+
+
+
+
+ {friends.length === 0 ? (
+ Ajoute d'abord des amis pour former un groupe.
+ ) : (
+ <>
+ SÉLECTIONNE TES AMIS
+ {friends.map((f) => {
+ const selected = selectedIds.includes(f.user._id);
+ return (
+ toggle(f.user._id)}
+ activeOpacity={0.8}
+ >
+
+ {f.user.pseudo}
+ Nv. {f.user.level}
+
+ );
+ })}
+ 4) && { opacity: 0.4 }]}
+ disabled={selectedIds.length === 0 || selectedIds.length > 4}
+ onPress={() => { onInvite(selectedIds, groupName.trim() || undefined); setSelectedIds([]); setGroupName(''); }}
+ activeOpacity={0.85}
+ >
+
+
+ Envoyer la demande ({selectedIds.length + 1}/5 membres)
+
+
+ >
+ )}
+ >
+ )}
+ >
+ );
+}
+
+// Barème de référence (miroir de computeGroupXpBonus côté backend) : à taille
+// et régularité fixées, quel multiplicateur peut-on espérer atteindre.
+const SIZE_SCALE = [1, 2, 3, 4, 5].map((n) => ({ n, multiplier: 1 + 0.35 * (n - 1) }));
+const REGULARITY_SCALE = [0, 7, 14, 21, 28, 35, 42, 49, 56].map((days) => ({
+ days,
+ multiplier: 1 + Math.min(0.6, 0.08 * Math.floor(days / 7)),
+}));
+
+function GroupCard({ group, myId, onShake, onCheckStreak, onLeaveGroup }) {
+ const flame = useRef(new Animated.Value(1)).current;
+ const [showScale, setShowScale] = useState(false);
+
+ useEffect(() => {
+ const loop = Animated.loop(
+ Animated.sequence([
+ Animated.timing(flame, { toValue: 1.18, duration: 700, useNativeDriver: true }),
+ Animated.timing(flame, { toValue: 1, duration: 700, useNativeDriver: true }),
+ ]),
+ );
+ loop.start();
+ return () => loop.stop();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const memberCount = group.members?.length ?? 0;
+ // xpBonus vient du backend (getMyGroup) — source de vérité pour le détail
+ // taille/régularité. Fallback taille-seule si absent (réponse en cache ancienne).
+ const xpBonus = group.xpBonus ?? { sizeMultiplier: 1 + 0.25 * (memberCount - 1), regularityMultiplier: 1, multiplier: 1 + 0.25 * (memberCount - 1) };
+
+ return (
+
+
+
+
+
+
+ {group.name || 'Groupe de Streak'}
+
+ Streak : {group.currentStreak ?? 0} jours
+
+
+
+
+ {/* ── Hall of Shame : reste affiché jusqu'à la prochaine streak validée ── */}
+ {(group.shameBreakers ?? []).length > 0 && (
+
+
+
+ Briseur{group.shameBreakers.length > 1 ? 's' : ''} de Streak actuel : {' '}
+
+ {group.shameBreakers.map((b) => b.pseudo).join(', ')}
+
+
+
+ )}
+
+ {/* ── Détail du multiplicateur d'XP ── */}
+
+
+
+ Multiplicateur d'XP
+ ×{xpBonus.multiplier.toFixed(2)}
+
+
+
+ Taille du groupe ({memberCount} membres)
+ ×{xpBonus.sizeMultiplier.toFixed(2)}
+
+
+
+ Régularité ({group.currentStreak ?? 0}j de streak)
+ ×{xpBonus.regularityMultiplier.toFixed(2)}
+
+
+ setShowScale((v) => !v)} activeOpacity={0.75}>
+ {showScale ? 'Masquer le barème' : 'Voir le barème complet'}
+
+
+
+ {showScale && (
+
+
+ Taille
+ {SIZE_SCALE.map((row) => (
+
+ {row.n} {row.n > 1 ? 'membres' : 'membre'}
+ ×{row.multiplier.toFixed(2)}
+
+ ))}
+
+
+ Régularité
+ {REGULARITY_SCALE.map((row) => (
+
+ {row.days === 0 ? '0 j' : `${row.days}+ j`}
+ ×{row.multiplier.toFixed(2)}
+
+ ))}
+
+
+ )}
+
+
+ MEMBRES ({memberCount}/5)
+ {(group.members ?? []).map((m) => {
+ const isMe = myId && m._id === myId;
+ const alreadyShaken = (group.shakenTodayByMe ?? []).includes(m._id);
+ return (
+
+ {!isMe && (
+ onShake(group._id, m._id, m.pseudo)}
+ activeOpacity={alreadyShaken ? 1 : 0.8}
+ disabled={alreadyShaken}
+ >
+
+
+ {alreadyShaken ? 'Secoué' : 'Secouer'}
+
+
+ )}
+
+ );
+ })}
+
+ onCheckStreak(group._id)} activeOpacity={0.85}>
+
+ Valider la streak du jour
+
+
+ onLeaveGroup(group._id)} activeOpacity={0.75}>
+
+ Quitter le groupe
+
+
+ );
+}
+
+// ═══ Composants partagés ══════════════════════════════════════════════════════
+
+function AnimatedRow({ index, children }) {
+ const slide = useRef(new Animated.Value(18)).current;
+ const fade = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(slide, { toValue: 0, duration: 280, delay: index * 55, useNativeDriver: true }),
+ Animated.timing(fade, { toValue: 1, duration: 280, delay: index * 55, useNativeDriver: true }),
+ ]).start();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Météo des séances : 4 états stricts remontés par le backend (getMyGroup)
+// dans `member.weatherStatus`. Affiché uniquement quand présent (les listes
+// amis/requêtes n'en portent pas — seuls les membres de groupe en ont un).
+const WEATHER_META = {
+ done: { icon: 'checkmark-circle', color: Colors.valid, label: 'Séance validée' },
+ active: { icon: 'flash', color: '#FBBF24', label: 'Séance active' },
+ ready: { icon: 'flame', color: Colors.primary, label: 'Prêt' },
+ sleeping: { icon: 'moon', color: Colors.textMuted, label: 'En sommeil' },
+};
+
+function UserRow({ user, subtitle, children }) {
+ const weather = user?.weatherStatus ? WEATHER_META[user.weatherStatus] : null;
+ return (
+
+
+ {(user?.pseudo ?? '?').charAt(0).toUpperCase()}
+ {weather && (
+
+
+
+ )}
+
+
+ {user?.pseudo ?? '-'}
+
+ Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'}
+ {weather ? ` · ${weather.label}` : ''}
+
+ {subtitle ? {subtitle} : null}
+
+ {children}
+
+ );
+}
+
+function FriendshipHearts({ level }) {
+ return (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function SmallBtn({ label, icon, color, onPress }) {
+ return (
+
+
+ {label ? {label} : null}
+
+ );
+}
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: Colors.bgAbyss },
+ title: {
+ color: Colors.textPrimary, fontSize: 26, fontWeight: '800',
+ letterSpacing: -0.5, paddingHorizontal: 16, paddingTop: 58, paddingBottom: 14,
+ },
+ loadingBox: { flex: 1, justifyContent: 'center', alignItems: 'center' },
+ content: { paddingHorizontal: 16 },
+
+ // ── Segments ──
+ segmentRow: {
+ flexDirection: 'row', gap: 8, paddingHorizontal: 16, marginBottom: 14,
+ },
+ segmentBtn: {
+ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ gap: 5, height: 38, borderRadius: 11,
+ backgroundColor: 'rgba(255,255,255,0.05)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)',
+ },
+ segmentBtnActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
+ segmentTxt: { color: Colors.textMuted, fontSize: 12.5, fontWeight: '700' },
+ segmentTxtActive: { color: '#fff' },
+ badge: {
+ backgroundColor: Colors.error, borderRadius: 9, minWidth: 17, height: 17,
+ justifyContent: 'center', alignItems: 'center', paddingHorizontal: 4,
+ },
+ badgeTxt: { color: '#fff', fontSize: 10, fontWeight: '800' },
+
+ sectionLabel: {
+ color: Colors.textMuted, fontSize: 11, fontWeight: '700',
+ letterSpacing: 0.8, marginTop: 20, marginBottom: 8, marginLeft: 4,
+ },
+
+ // ── Ajout d'ami ──
+ addFriendBtn: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: Colors.primary,
+ borderRadius: 13, height: 48, marginBottom: 4,
+ shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.35, shadowRadius: 10, elevation: 5,
+ },
+ addFriendBtnTxt: { color: '#fff', fontSize: 14.5, fontWeight: '700' },
+ sentTagRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginRight: 8 },
+ sentTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' },
+
+ // ── Lignes utilisateur ──
+ userRow: {
+ flexDirection: 'row', alignItems: 'center',
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1, borderColor: Colors.borderSubtle,
+ borderRadius: 14, padding: 12, marginBottom: 8,
+ },
+ avatar: {
+ width: 40, height: 40, borderRadius: 12,
+ backgroundColor: 'rgba(254,116,57,0.14)',
+ justifyContent: 'center', alignItems: 'center', marginRight: 11,
+ },
+ avatarTxt: { color: Colors.primary, fontSize: 16, fontWeight: '800' },
+ weatherBadge: {
+ position: 'absolute', bottom: -4, right: -4,
+ width: 18, height: 18, borderRadius: 9,
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1.5, borderColor: Colors.background,
+ justifyContent: 'center', alignItems: 'center',
+ },
+ userPseudo: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' },
+ userMeta: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 },
+ userFriendshipTitle: { color: Colors.primary, fontSize: 10.5, fontWeight: '700', marginTop: 2 },
+ userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 },
+ hearts: { flexDirection: 'row', alignItems: 'center' },
+
+ smallBtn: {
+ flexDirection: 'row', alignItems: 'center', gap: 4,
+ borderWidth: 1, borderRadius: 9, paddingHorizontal: 10, height: 32,
+ },
+ smallBtnTxt: { fontSize: 12, fontWeight: '700' },
+
+ // ── Podium ──
+ podiumRow: {
+ flexDirection: 'row', alignItems: 'flex-end', gap: 10,
+ marginTop: 18, marginBottom: 6, paddingHorizontal: 8,
+ },
+ podiumCol: { flex: 1, alignItems: 'center' },
+ podiumPseudo: { color: Colors.textPrimary, fontSize: 12.5, fontWeight: '700', marginBottom: 2 },
+ podiumXp: { fontSize: 11, fontWeight: '800', marginBottom: 6 },
+ podiumBar: {
+ width: '100%', borderRadius: 12, borderWidth: 1,
+ justifyContent: 'flex-start', alignItems: 'center', paddingTop: 8,
+ },
+ podiumMedal: { alignItems: 'center', justifyContent: 'center' },
+
+ boardRow: {
+ flexDirection: 'row', alignItems: 'center',
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1, borderColor: Colors.borderSubtle,
+ borderRadius: 12, paddingHorizontal: 14, height: 46, marginBottom: 7,
+ },
+ boardRowMe: { borderColor: 'rgba(254,116,57,0.45)', backgroundColor: 'rgba(254,116,57,0.06)' },
+ boardPos: { width: 36 },
+ boardPosTxt: { color: Colors.textPrimary, fontSize: 13, fontWeight: '800' },
+ boardPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' },
+ boardXp: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '700' },
+ boardKg: { color: Colors.primary, fontSize: 13.5, fontWeight: '800' },
+ boardReps: { color: Colors.textMuted, fontSize: 11.5, fontWeight: '600', marginLeft: 4, width: 34 },
+
+ // ── Bascule XP / Records ──
+ modeRow: { flexDirection: 'row', gap: 8, marginTop: 4, marginBottom: 4 },
+ modeBtn: {
+ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ gap: 5, height: 34, borderRadius: 10,
+ backgroundColor: 'rgba(255,255,255,0.05)',
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)',
+ },
+ modeBtnActive: { backgroundColor: Colors.secondaryAccent, borderColor: Colors.secondaryAccent },
+ modeTxt: { color: Colors.textMuted, fontSize: 12, fontWeight: '700' },
+ modeTxtActive: { color: '#fff' },
+
+ // ── Chips d'exercices ──
+ exoChipRow: { gap: 6, paddingVertical: 10, paddingRight: 4 },
+ exoChip: {
+ paddingHorizontal: 12, paddingVertical: 7, borderRadius: 9,
+ borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)',
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ },
+ exoChipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
+ exoChipMore: { flexDirection: 'row', alignItems: 'center', borderColor: `${Colors.primary}50`, backgroundColor: `${Colors.primary}12` },
+ exoChipTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' },
+ exoChipTxtActive: { color: '#fff' },
+ recordsLoading: { paddingVertical: 30, alignItems: 'center' },
+
+ // ── Groupe ──
+ inviteCard: {
+ backgroundColor: 'rgba(254,116,57,0.07)',
+ borderWidth: 1, borderColor: 'rgba(254,116,57,0.30)',
+ borderRadius: 14, padding: 14, marginBottom: 10,
+ },
+ inviteTxtRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
+ inviteTxt: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, lineHeight: 19 },
+ inviteBtns: { flexDirection: 'row', gap: 8 },
+
+ groupCard: {
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1, borderColor: Colors.borderSubtle,
+ borderRadius: 18, padding: 16, marginTop: 6,
+ },
+ groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 },
+ groupFlameWrap: {
+ width: 52, height: 52, borderRadius: 16,
+ backgroundColor: 'rgba(254,116,57,0.12)',
+ justifyContent: 'center', alignItems: 'center',
+ },
+ groupName: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' },
+ groupStreak: { color: Colors.textSecondary, fontSize: 12.5, marginTop: 3 },
+
+ // ── Hall of Shame ──
+ shameBanner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(56,189,248,0.10)',
+ borderWidth: 1,
+ borderColor: 'rgba(56,189,248,0.35)',
+ borderRadius: 12,
+ padding: 10,
+ marginTop: 12,
+ gap: 8,
+ },
+ shameTxt: { color: Colors.textSecondary, fontSize: 12.5, flex: 1, lineHeight: 18 },
+ shameNames: { color: '#38BDF8', fontWeight: '800' },
+
+ // ── Détail multiplicateur ──
+ multiplierCard: {
+ backgroundColor: 'rgba(255,215,0,0.05)',
+ borderWidth: 1, borderColor: 'rgba(255,215,0,0.18)',
+ borderRadius: 14, padding: 12, marginTop: 14,
+ },
+ multiplierHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 },
+ multiplierTitle: { flex: 1, color: Colors.textPrimary, fontSize: 12.5, fontWeight: '700' },
+ multiplierTotal: { color: Colors.gold, fontSize: 14, fontWeight: '800' },
+ multiplierRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 },
+ multiplierLabel: { flex: 1, color: Colors.textMuted, fontSize: 11.5 },
+ multiplierValue: { color: Colors.textSecondary, fontSize: 11.5, fontWeight: '700' },
+
+ scaleToggle: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5,
+ marginTop: 10, paddingTop: 10,
+ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,215,0,0.18)',
+ },
+ scaleToggleTxt: { color: Colors.gold, fontSize: 11.5, fontWeight: '700' },
+ scaleWrap: { flexDirection: 'row', gap: 14, marginTop: 12 },
+ scaleCol: { flex: 1 },
+ scaleColTitle: {
+ color: Colors.textMuted, fontSize: 10.5, fontWeight: '800',
+ letterSpacing: 0.6, marginBottom: 6,
+ },
+ scaleRow: {
+ flexDirection: 'row', justifyContent: 'space-between',
+ paddingVertical: 3,
+ },
+ scaleRowLabel: { color: Colors.textSecondary, fontSize: 11.5 },
+ scaleRowValue: { color: Colors.textPrimary, fontSize: 11.5, fontWeight: '700' },
+
+ shakeBtn: {
+ flexDirection: 'row', alignItems: 'center',
+ backgroundColor: 'rgba(255,77,77,0.10)',
+ borderWidth: 1, borderColor: 'rgba(255,77,77,0.40)',
+ borderRadius: 9, paddingHorizontal: 10, height: 32, justifyContent: 'center',
+ },
+ shakeBtnDisabled: {
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ borderColor: Colors.borderSubtle,
+ },
+ shakeTxt: { color: Colors.error, fontSize: 11.5, fontWeight: '800' },
+ shakeTxtDisabled: { color: Colors.textMuted },
+
+ leaveBtn: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ height: 42, borderRadius: 13, marginTop: 10,
+ borderWidth: 1, borderColor: 'rgba(255,77,77,0.30)',
+ },
+ leaveBtnTxt: { color: Colors.error, fontSize: 13, fontWeight: '700' },
+
+ selectRow: {
+ flexDirection: 'row', alignItems: 'center', gap: 10,
+ backgroundColor: Colors.cardDeep,
+ borderWidth: 1, borderColor: Colors.borderSubtle,
+ borderRadius: 12, padding: 12, marginBottom: 9,
+ },
+ selectRowActive: { borderColor: 'rgba(254,116,57,0.45)' },
+ selectPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' },
+ selectLevel: { color: Colors.textMuted, fontSize: 12 },
+
+ ctaBtn: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: Colors.primary, borderRadius: 13, height: 48, marginTop: 14,
+ },
+ ctaTxt: { color: '#fff', fontSize: 14, fontWeight: '800' },
+
+ // ── Vide ──
+ emptyBox: { alignItems: 'center', paddingVertical: 36 },
+ emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' },
+ emptySmall: { color: Colors.textMuted, fontSize: 12.5, marginTop: 8, marginLeft: 4 },
+});
diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js
index fed316d..2f8252d 100644
--- a/front/src/screens/Stats/StatsScreen.js
+++ b/front/src/screens/Stats/StatsScreen.js
@@ -1,20 +1,26 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import {
- View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert,
+ View, Text, ScrollView, TouchableOpacity, StyleSheet,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { Colors } from '../../constants/theme';
import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
-import { aggregateGlobal } from '../../services/stats.service';
+import { useUser } from '../../context/UserContext';
+import { aggregateGlobal } from '../../services';
+import { getWeightHistory } from '../../services';
import PeriodSegmentedControl from '../../components/stats/PeriodSegmentedControl';
import VolumeBarChart from '../../components/stats/VolumeBarChart';
import MuscleDistributionPieChart from '../../components/stats/MuscleDistributionPieChart';
+import WeightProgressChart from '../../components/stats/WeightProgressChart';
import WorkoutCalendar from '../../components/stats/WorkoutCalendar';
import XPProgressBar from '../../components/stats/XPProgressBar';
import WorkoutHistoryList from '../../components/stats/WorkoutHistoryList';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
+import { WeightEntryModal } from '../../components/common';
+import { ConfirmModal } from '../../components/common';
+import { InfoModal } from '../../components/common';
import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats';
@@ -25,12 +31,32 @@ const TABS = [
export default function StatsScreen({ navigation }) {
const { sessionLogs: realLogs, totalXP, remove } = useWorkoutLogs();
+ const { user } = useUser();
+
+ const [errorInfo, setErrorInfo] = useState(null);
+ const [dayDetail, setDayDetail] = useState(null); // { log, body, deletable }
+ const [noSessionInfo, setNoSessionInfo] = useState(null); // dateKey
const handleDelete = useCallback(async (id) => {
try { await remove(id); } catch (e) {
- Alert.alert('Erreur', e?.message || 'Suppression impossible');
+ setErrorInfo(e?.message || 'Suppression impossible');
}
}, [remove]);
+
+ // ─── Suivi de poids (Section VI) ─────────────────────────────────────────
+ const [weightHistory, setWeightHistory] = useState([]);
+ const [weightEntryVisible, setWeightEntryVisible] = useState(false);
+
+ const loadWeightHistory = useCallback(async () => {
+ try {
+ const res = await getWeightHistory();
+ setWeightHistory(Array.isArray(res.history) ? res.history : []);
+ } catch (_) {
+ // Best-effort — un historique de poids manquant ne doit jamais bloquer l'écran.
+ }
+ }, []);
+
+ useFocusEffect(useCallback(() => { loadWeightHistory(); }, [loadWeightHistory]));
const [tab, setTab] = useState('performance');
const [period, setPeriod] = useState('month');
@@ -41,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');
@@ -50,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, 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);
});
- }, [registerScrollRef, registerRemeasure, rKpis, rVolume, rMuscle]);
+ }, []);
// Démarrage du chapitre quand l'écran gagne le focus.
// On force d'abord l'onglet Performance pour éviter que l'utilisateur,
@@ -67,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);
@@ -98,34 +164,33 @@ export default function StatsScreen({ navigation }) {
const onSelectDate = useCallback((dateKey) => {
const matching = activeLogs.filter((l) => l.date && l.date.slice(0, 10) === dateKey);
if (matching.length === 0) {
- Alert.alert('Aucune séance', `Pas de séance le ${dateKey}.`);
+ setNoSessionInfo(dateKey);
return;
}
const log = matching[0];
- Alert.alert(
- log.name,
- [`Volume: ${Math.round(log.totalVolume)} kg`, `Sets: ${log.setsCompleted}`, `XP: ${log.xpEarned}`].join('\n'),
- [
- { text: 'Fermer', style: 'cancel' },
- ...(activeChapterId !== 'stats' ? [{
- text: 'Supprimer', style: 'destructive',
- onPress: async () => {
- try { await remove(log.id); } catch (e) {
- Alert.alert('Erreur', e?.message || 'Suppression impossible');
- }
- },
- }] : []),
- ],
- );
- }, [activeLogs, remove, activeChapterId]);
+ setDayDetail({
+ log,
+ body: [`Volume: ${Math.round(log.totalVolume)} kg`, `Sets: ${log.setsCompleted}`, `XP: ${log.xpEarned}`].join('\n'),
+ deletable: activeChapterId !== 'stats',
+ });
+ }, [activeLogs, activeChapterId]);
+
+ const confirmDeleteDayDetail = useCallback(async () => {
+ const log = dayDetail?.log;
+ setDayDetail(null);
+ if (!log) return;
+ try { await remove(log.id); } catch (e) {
+ setErrorInfo(e?.message || 'Suppression impossible');
+ }
+ }, [dayDetail, remove]);
return (
{/* Bandeau mock data */}
{activeChapterId === 'stats' && (
-
- Données de démonstration — disparaîtront à la fin du chapitre
+
+ Données de démonstration - disparaîtront à la fin du chapitre
)}
@@ -134,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
@@ -169,6 +236,20 @@ export default function StatsScreen({ navigation }) {
+
+
+
+ setWeightEntryVisible(true)}
+ activeOpacity={0.85}
+ >
+
+ Ajouter une pesée
+
+
+
+
@@ -207,6 +288,52 @@ export default function StatsScreen({ navigation }) {
{activeChapterId === 'stats' && (
)}
+
+ setWeightEntryVisible(false)}
+ onSaved={loadWeightHistory}
+ />
+
+ {dayDetail?.deletable ? (
+ setDayDetail(null)}
+ />
+ ) : (
+ setDayDetail(null)}
+ />
+ )}
+
+ setNoSessionInfo(null)}
+ />
+
+ setErrorInfo(null)}
+ />
);
}
@@ -241,7 +368,7 @@ const styles = StyleSheet.create({
borderBottomWidth: 1, borderBottomColor: 'rgba(255,215,0,0.25)',
paddingHorizontal: 16, paddingVertical: 8,
},
- mockBannerText: { color: '#FFD700', fontSize: 11, fontWeight: '600', flex: 1 },
+ mockBannerText: { color: Colors.gold, fontSize: 11, fontWeight: '600', flex: 1 },
header: { marginBottom: 16 },
title: { color: Colors.textPrimary, fontSize: 26, fontWeight: '900', letterSpacing: -0.5 },
@@ -273,6 +400,13 @@ const styles = StyleSheet.create({
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
},
cardTitle: { color: Colors.textPrimary, fontSize: 14, fontWeight: '800', marginBottom: 12 },
+ addWeightBtn: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ marginTop: 12, height: 40, borderRadius: 11,
+ backgroundColor: `${Colors.primary}14`,
+ borderWidth: 1, borderColor: `${Colors.primary}40`,
+ },
+ addWeightBtnTxt: { color: Colors.primary, fontSize: 13, fontWeight: '700' },
emptyText: { color: Colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 20, paddingVertical: 12 },
diff --git a/front/src/screens/Workouts/CustomExercisesScreen.js b/front/src/screens/Workouts/CustomExercisesScreen.js
index dda6d24..0e163b5 100644
--- a/front/src/screens/Workouts/CustomExercisesScreen.js
+++ b/front/src/screens/Workouts/CustomExercisesScreen.js
@@ -1,11 +1,10 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
FlatList,
- Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@@ -17,9 +16,13 @@ import {
secondaryMusclesLabels,
primaryEquipmentLabel,
} from '../../constants/exerciseFilters';
+import { ConfirmModal } from '../../components/common';
+import { InfoModal } from '../../components/common';
export default function CustomExercisesScreen({ navigation }) {
const { items, loading, remove } = useCustomExercises();
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [errorInfo, setErrorInfo] = useState(null);
const onAdd = useCallback(() => {
navigation && navigation.navigate('EditExercise', { mode: 'create' });
@@ -29,24 +32,15 @@ export default function CustomExercisesScreen({ navigation }) {
navigation && navigation.navigate('EditExercise', { mode: 'edit', exerciseId: item.id });
}, [navigation]);
- const onDelete = useCallback((item) => {
- Alert.alert(
- 'Supprimer',
- `Supprimer "${item.name}" ? Les séances qui l'utilisent ne seront pas affectées.`,
- [
- { text: 'Annuler', style: 'cancel' },
- {
- text: 'Supprimer',
- style: 'destructive',
- onPress: async () => {
- try { await remove(item.id); } catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible');
- }
- },
- },
- ],
- );
- }, [remove]);
+ const onDelete = useCallback((item) => setDeleteTarget(item), []);
+
+ const confirmDelete = useCallback(async () => {
+ const item = deleteTarget;
+ setDeleteTarget(null);
+ try { await remove(item.id); } catch (e) {
+ setErrorInfo(e && e.message ? e.message : 'Suppression impossible');
+ }
+ }, [deleteTarget, remove]);
const renderItem = ({ item }) => {
const icon = pickExerciseIcon(item);
@@ -61,7 +55,7 @@ export default function CustomExercisesScreen({ navigation }) {
activeOpacity={0.85}
>
- {icon}
+
{item.name}
@@ -122,6 +116,25 @@ export default function CustomExercisesScreen({ navigation }) {
ItemSeparatorComponent={() => }
/>
)}
+
+ setDeleteTarget(null)}
+ />
+ setErrorInfo(null)}
+ />
);
}
@@ -136,7 +149,7 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
+ borderBottomColor: Colors.borderSubtle,
},
headerTitle: {
color: Colors.textPrimary,
@@ -161,7 +174,7 @@ const styles = StyleSheet.create({
width: 50,
height: 50,
borderRadius: 12,
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
diff --git a/front/src/screens/Workouts/EditExerciseScreen.js b/front/src/screens/Workouts/EditExerciseScreen.js
index 962ecbb..e519796 100644
--- a/front/src/screens/Workouts/EditExerciseScreen.js
+++ b/front/src/screens/Workouts/EditExerciseScreen.js
@@ -6,7 +6,6 @@ import {
TouchableOpacity,
ScrollView,
StyleSheet,
- Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
@@ -18,6 +17,8 @@ import { EQUIPMENTS, LEVELS } from '../../constants/exerciseFilters';
import SelectableChip from '../../components/workouts/SelectableChip';
import MuscleHierarchyPicker from '../../components/workouts/MuscleHierarchyPicker';
import { useCustomExercises } from '../../context/CustomExercisesContext';
+import { ConfirmModal } from '../../components/common';
+import { InfoModal } from '../../components/common';
// Form add/edit d'un exercice perso. Aligné sur la structure du catalogue (sous-muscles
// précis), donc directement compatible avec le Builder et l'algo de tri.
@@ -49,6 +50,8 @@ export default function EditExerciseScreen({ route, navigation }) {
const [videoUrl, setVideoUrl] = useState(existing ? existing.videoUrl || '' : '');
const [notes, setNotes] = useState(existing ? existing.notes || '' : '');
const [saving, setSaving] = useState(false);
+ const [infoModal, setInfoModal] = useState(null); // { title, body }
+ const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false);
const toggle = useCallback((arr, value) => (
arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]
@@ -65,11 +68,11 @@ export default function EditExerciseScreen({ route, navigation }) {
const handleSave = useCallback(async () => {
if (!name.trim()) {
- Alert.alert('Nom requis', 'Donne un nom à ton exercice.');
+ setInfoModal({ title: 'Nom requis', body: 'Donne un nom à ton exercice.' });
return;
}
if (!targetMuscle.trim()) {
- Alert.alert('Muscle principal requis', 'Sélectionne le sous-muscle principal travaillé.');
+ setInfoModal({ title: 'Muscle principal requis', body: 'Sélectionne le sous-muscle principal travaillé.' });
return;
}
setSaving(true);
@@ -90,7 +93,7 @@ export default function EditExerciseScreen({ route, navigation }) {
}
if (navigation) navigation.goBack();
} catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible');
+ setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' });
} finally {
setSaving(false);
}
@@ -98,26 +101,18 @@ export default function EditExerciseScreen({ route, navigation }) {
const handleDelete = useCallback(() => {
if (!exerciseId) return;
- Alert.alert(
- 'Supprimer',
- `Supprimer "${name || 'cet exercice'}" ?`,
- [
- { text: 'Annuler', style: 'cancel' },
- {
- text: 'Supprimer',
- style: 'destructive',
- onPress: async () => {
- try {
- await remove(exerciseId);
- if (navigation) navigation.goBack();
- } catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible');
- }
- },
- },
- ],
- );
- }, [exerciseId, name, remove, navigation]);
+ setDeleteConfirmVisible(true);
+ }, [exerciseId]);
+
+ const confirmDelete = useCallback(async () => {
+ setDeleteConfirmVisible(false);
+ try {
+ await remove(exerciseId);
+ if (navigation) navigation.goBack();
+ } catch (e) {
+ setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Suppression impossible' });
+ }
+ }, [exerciseId, remove, navigation]);
return (
@@ -262,6 +257,25 @@ export default function EditExerciseScreen({ route, navigation }) {
) : null}
+
+ setDeleteConfirmVisible(false)}
+ />
+ setInfoModal(null)}
+ />
);
}
@@ -289,7 +303,7 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
+ borderBottomColor: Colors.borderSubtle,
},
headerTitle: {
color: Colors.textPrimary,
diff --git a/front/src/screens/Workouts/ExerciseDetailScreen.js b/front/src/screens/Workouts/ExerciseDetailScreen.js
index 9cbeb23..b8490e0 100644
--- a/front/src/screens/Workouts/ExerciseDetailScreen.js
+++ b/front/src/screens/Workouts/ExerciseDetailScreen.js
@@ -18,7 +18,7 @@ import {
primaryMuscleLabel,
secondaryMusclesLabels,
} from '../../constants/exerciseFilters';
-import useEffortTimer from '../../hooks/useEffortTimer';
+import { useEffortTimer } from '../../hooks';
import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext';
import ExerciseHeader from '../../components/workouts/ExerciseHeader';
import SetTable from '../../components/workouts/SetTable';
@@ -74,6 +74,11 @@ export default function ExerciseDetailScreen({ route, navigation }) {
actions.addSet(exerciseIndex);
}, [actions, exerciseIndex]);
+ const handleRemove = useCallback((i) => {
+ if (exerciseIndex < 0 || sets.length <= 1) return;
+ actions.removeSet(exerciseIndex, i);
+ }, [actions, exerciseIndex, sets.length]);
+
const handleNotes = useCallback((text) => {
if (exerciseIndex < 0) return;
actions.updateExerciseNotes(exerciseIndex, text);
@@ -161,6 +166,7 @@ export default function ExerciseDetailScreen({ route, navigation }) {
sets={sets}
onToggle={handleToggle}
onChange={handleChange}
+ onRemove={handleRemove}
/>
-
-
+
+
@@ -138,7 +138,7 @@ function LastSessionCard({ session }) {
{w > 0 ? `${w} kg` : 'PC'}
- {r > 0 ? r : '—'}
+ {r > 0 ? r : '-'}
- {icon}
+
{exercise.name}
@@ -98,7 +98,7 @@ function ExerciseRow({ exercise, index, onRemove, onSetsChange, onRepsChange })
onRepsChange(index, n);
}}
keyboardType="numeric"
- placeholder="—"
+ placeholder="-"
placeholderTextColor={Colors.textMuted}
style={styles.repsInput}
maxLength={2}
@@ -111,13 +111,18 @@ function ExerciseRow({ exercise, index, onRemove, onSetsChange, onRepsChange })
);
}
-export default function ManualWorkoutCreatorScreen({ navigation }) {
- const { create: createSavedWorkout } = useSavedWorkouts();
+export default function ManualWorkoutCreatorScreen({ navigation, route }) {
+ const { create: createSavedWorkout, update: updateSavedWorkout } = useSavedWorkouts();
- const [name, setName] = useState('');
- const [exercises, setExercises] = useState([]);
+ // Édition d'une séance existante (venant de WorkoutListScreen → "Modifier",
+ // pour les séances manuelles ou celles sans critères de génération stockés).
+ const editWorkout = route?.params?.editWorkout ?? null;
+
+ const [name, setName] = useState(editWorkout?.name ?? '');
+ const [exercises, setExercises] = useState(editWorkout?.exercises ?? []);
const [sheetVisible, setSheetVisible] = useState(false);
const [saving, setSaving] = useState(false);
+ const [infoModal, setInfoModal] = useState(null); // { title, body, onCloseNav? }
const buildSets = (count, reps) => {
const c = Math.max(1, Math.min(20, count || 4));
@@ -167,34 +172,47 @@ export default function ManualWorkoutCreatorScreen({ navigation }) {
const handleSave = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) {
- Alert.alert('Nom requis', 'Donne un nom à ta séance.');
+ setInfoModal({ title: 'Nom requis', body: 'Donne un nom à ta séance.' });
return;
}
if (exercises.length === 0) {
- Alert.alert('Au moins un exercice', 'Ajoute au moins un exercice avant de sauvegarder.');
+ setInfoModal({ title: 'Au moins un exercice', body: 'Ajoute au moins un exercice avant de sauvegarder.' });
return;
}
setSaving(true);
try {
- await createSavedWorkout({
- name: trimmed,
- description: '',
- exercises,
- isManual: true,
- });
- Alert.alert(
- 'Séance créée',
- `"${trimmed}" est dans tes séances. Tu peux la lancer depuis la page Séances.`,
- [
- { text: 'OK', onPress: () => navigation && navigation.goBack() },
- ],
- );
+ if (editWorkout?.id) {
+ await updateSavedWorkout(editWorkout.id, { name: trimmed, description: '', exercises });
+ setInfoModal({
+ title: 'Séance mise à jour',
+ body: `"${trimmed}" a été modifiée.`,
+ onCloseNav: true,
+ });
+ } else {
+ await createSavedWorkout({
+ name: trimmed,
+ description: '',
+ exercises,
+ isManual: true,
+ });
+ setInfoModal({
+ title: 'Séance créée',
+ body: `"${trimmed}" est dans tes séances. Tu peux la lancer depuis la page Séances.`,
+ onCloseNav: true,
+ });
+ }
} catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible');
+ setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' });
} finally {
setSaving(false);
}
- }, [name, exercises, createSavedWorkout, navigation]);
+ }, [name, exercises, editWorkout, createSavedWorkout, updateSavedWorkout, navigation]);
+
+ const closeInfoModal = useCallback(() => {
+ const shouldGoBack = infoModal?.onCloseNav;
+ setInfoModal(null);
+ if (shouldGoBack && navigation) navigation.goBack();
+ }, [infoModal, navigation]);
return (
@@ -209,7 +227,7 @@ export default function ManualWorkoutCreatorScreen({ navigation }) {
>
- Créer une séance
+ {editWorkout ? 'Modifier la séance' : 'Créer une séance'}
@@ -298,6 +316,15 @@ export default function ManualWorkoutCreatorScreen({ navigation }) {
onSelect={handleAddExercise}
/>
+
+
);
}
@@ -313,7 +340,7 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
+ borderBottomColor: Colors.borderSubtle,
},
headerTitle: {
color: Colors.textPrimary,
@@ -389,7 +416,7 @@ const styles = StyleSheet.create({
width: 44,
height: 44,
borderRadius: 10,
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
@@ -443,7 +470,7 @@ const styles = StyleSheet.create({
stepper: {
flexDirection: 'row',
alignItems: 'center',
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
borderRadius: 10,
paddingHorizontal: 4,
},
@@ -461,7 +488,7 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
repsInput: {
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
color: Colors.textPrimary,
fontSize: 14,
fontWeight: '700',
@@ -501,7 +528,7 @@ const styles = StyleSheet.create({
paddingBottom: 26,
backgroundColor: Colors.background,
borderTopWidth: StyleSheet.hairlineWidth,
- borderTopColor: '#1f1f27',
+ borderTopColor: Colors.borderSubtle,
},
saveBtn: {
flexDirection: 'row',
diff --git a/front/src/screens/Workouts/WorkoutBuilderScreen.js b/front/src/screens/Workouts/WorkoutBuilderScreen.js
index d18fe46..3c81fa8 100644
--- a/front/src/screens/Workouts/WorkoutBuilderScreen.js
+++ b/front/src/screens/Workouts/WorkoutBuilderScreen.js
@@ -2,10 +2,10 @@ import React, { useState, useMemo, useCallback } from 'react';
import {
View,
Text,
+ TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
- Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@@ -20,7 +20,10 @@ import MuscleHierarchyPicker from '../../components/workouts/MuscleHierarchyPick
import { useCustomExercises } from '../../context/CustomExercisesContext';
import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext';
import { useSavedWorkouts } from '../../context/SavedWorkoutsContext';
+import { useToast } from '../../context/ToastContext';
import { generateWorkout } from '../../data/exerciseCatalog';
+import InfoModal from '../../components/common/InfoModal';
+import SaveWorkoutPromptModal from '../../components/workouts/SaveWorkoutPromptModal';
const DURATIONS = [
{ id: 30, label: '30 min' },
@@ -30,17 +33,41 @@ const DURATIONS = [
{ id: 90, label: '90 min' },
];
-export default function WorkoutBuilderScreen({ navigation }) {
+const DEFAULT_WORKOUT_NAME = 'Séance personnalisée';
+
+export default function WorkoutBuilderScreen({ navigation, route }) {
const { items: customExercises } = useCustomExercises();
const { loadWorkout } = useWorkoutInProgress();
- const { create: createSavedWorkout } = useSavedWorkouts();
+ const { create: createSavedWorkout, update: updateSavedWorkout, remove: removeSavedWorkout } = useSavedWorkouts();
+ const { showToast } = useToast();
+
+ // Édition d'une séance existante (venant de WorkoutListScreen → "Modifier") :
+ // pré-remplit les critères et le libellé, et bascule le toggle Sauver sur
+ // "déjà sauvegardée" puisqu'on modifie une entrée qui existe déjà.
+ const editWorkout = route?.params?.editWorkout ?? null;
+ const editCriteria = editWorkout?.criteria ?? null;
- const [subMuscles, setSubMuscles] = useState([]);
- const [equipment, setEquipment] = useState([]);
- const [level, setLevel] = useState('');
- const [duration, setDuration] = useState(60);
+ const [subMuscles, setSubMuscles] = useState(editCriteria?.subMuscles ?? []);
+ const [equipment, setEquipment] = useState(editCriteria?.equipment ?? []);
+ const [level, setLevel] = useState(editCriteria?.level ?? '');
+ const [duration, setDuration] = useState(editCriteria?.durationMin ?? 60);
const [regenKey, setRegenKey] = useState(0);
const [saving, setSaving] = useState(false);
+ const [infoModal, setInfoModal] = useState(null); // { title, body }
+ const [savePromptVisible, setSavePromptVisible] = useState(false);
+ const [workoutName, setWorkoutName] = useState(editWorkout?.name || DEFAULT_WORKOUT_NAME);
+ // Non-null ⇔ la séance actuellement en aperçu correspond exactement à une
+ // entrée déjà sauvegardée (icône "Sauver" pleine). Invalidé dès que les
+ // critères ou l'aperçu changent, puisque le contenu ne correspond plus.
+ const [savedWorkoutId, setSavedWorkoutId] = useState(editWorkout?.id ?? null);
+ // Id de l'entrée à METTRE À JOUR (plutôt que dupliquer) à la prochaine
+ // sauvegarde — distinct de savedWorkoutId : reste valide même après un
+ // changement de critères (l'entrée d'origine existe toujours en storage),
+ // et n'est effacé que si cette entrée est explicitement supprimée (toggle
+ // off), pour ne jamais tenter de mettre à jour un id déjà supprimé.
+ const [editingId, setEditingId] = useState(editWorkout?.id ?? null);
+
+ const invalidateSaved = useCallback(() => setSavedWorkoutId(null), []);
const toggleArr = useCallback((arr, value) => (
arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]
@@ -61,34 +88,93 @@ export default function WorkoutBuilderScreen({ navigation }) {
const onRegenerate = useCallback(() => {
setRegenKey((k) => k + 1);
- }, []);
+ invalidateSaved();
+ }, [invalidateSaved]);
- const onLaunch = useCallback(() => {
+ // Lance réellement le chrono (navigation vers l'écran de séance active).
+ const launchWorkout = useCallback(() => {
if (!preview || preview.exercises.length === 0) return;
loadWorkout(preview);
if (navigation) navigation.navigate('Workout', { workout: preview });
}, [preview, loadWorkout, navigation]);
- const onSave = useCallback(async () => {
+ // Bouton "Lancer la séance" : si l'aperçu courant est déjà sauvegardé (toggle
+ // actif), on lance directement — inutile de redemander. Sinon on intercepte
+ // pour proposer la sauvegarde avant d'ouvrir le chrono.
+ const onLaunch = useCallback(() => {
+ if (!preview || preview.exercises.length === 0) return;
+ if (savedWorkoutId) { launchWorkout(); return; }
+ setSavePromptVisible(true);
+ }, [preview, savedWorkoutId, launchWorkout]);
+
+ // Sauvegarde (création OU mise à jour si editingId pointe vers une entrée
+ // existante) l'aperçu courant sous workoutName, avec ses critères de
+ // génération pour permettre une future modification pré-remplie.
+ const persistCurrentPreview = useCallback(async () => {
+ const trimmedName = workoutName.trim() || DEFAULT_WORKOUT_NAME;
+ const payload = {
+ name: trimmedName,
+ description: preview.description,
+ exercises: preview.exercises,
+ criteria: { subMuscles, equipment, level, durationMin: duration },
+ };
+ if (editingId) {
+ return updateSavedWorkout(editingId, payload);
+ }
+ return createSavedWorkout(payload);
+ }, [workoutName, preview, subMuscles, equipment, level, duration, editingId, createSavedWorkout, updateSavedWorkout]);
+
+ // Bouton "Sauver" du bandeau d'aperçu — toggle : sauvegarde si absent,
+ // retire des séances si déjà sauvegardé (l'icône passe pleine ↔ contour).
+ const onToggleSave = useCallback(async () => {
+ if (!preview || preview.exercises.length === 0) return;
+ setSaving(true);
+ try {
+ if (savedWorkoutId) {
+ await removeSavedWorkout(savedWorkoutId);
+ setSavedWorkoutId(null);
+ setEditingId(null);
+ showToast('Retirée de tes séances', 'success');
+ } else {
+ const item = await persistCurrentPreview();
+ setSavedWorkoutId(item.id);
+ setEditingId(item.id);
+ showToast('Séance sauvegardée', 'success');
+ }
+ } catch (e) {
+ setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Action impossible' });
+ } finally {
+ setSaving(false);
+ }
+ }, [preview, savedWorkoutId, persistCurrentPreview, removeSavedWorkout, showToast]);
+
+ const handleSaveAndLaunch = useCallback(async () => {
if (!preview || preview.exercises.length === 0) return;
setSaving(true);
try {
- await createSavedWorkout({
- name: preview.name,
- description: preview.description,
- exercises: preview.exercises,
- });
- Alert.alert(
- 'Sauvegardé',
- `"${preview.name}" est dans tes séances. Retrouve-la dans la page Séances.`,
- [{ text: 'OK' }],
- );
+ const item = await persistCurrentPreview();
+ setSavedWorkoutId(item.id);
+ setEditingId(item.id);
} catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible');
+ // La séance se lance quand même : un échec de sauvegarde ne doit jamais
+ // empêcher l'entraînement. On informe juste via un toast non bloquant
+ // (l'utilisateur pourra retenter avec le bouton "Sauver" dédié).
+ showToast(e && e.message ? e.message : 'Sauvegarde impossible, séance lancée sans template.', 'error');
} finally {
setSaving(false);
+ setSavePromptVisible(false);
+ launchWorkout();
}
- }, [preview, createSavedWorkout]);
+ }, [preview, persistCurrentPreview, launchWorkout, showToast]);
+
+ const handleLaunchWithoutSave = useCallback(() => {
+ setSavePromptVisible(false);
+ launchWorkout();
+ }, [launchWorkout]);
+
+ const handleDismissSavePrompt = useCallback(() => {
+ setSavePromptVisible(false);
+ }, []);
const totalSelectedCount = subMuscles.length;
@@ -101,7 +187,7 @@ export default function WorkoutBuilderScreen({ navigation }) {
>
- Crée ta séance
+ {editWorkout ? 'Modifier ta séance' : 'Crée ta séance'}
@@ -119,7 +205,7 @@ export default function WorkoutBuilderScreen({ navigation }) {
{ setSubMuscles(v); invalidateSaved(); }}
showSelectAll
/>
@@ -131,7 +217,7 @@ export default function WorkoutBuilderScreen({ navigation }) {
key={eq.id}
label={eq.label}
selected={equipment.includes(eq.label)}
- onPress={() => setEquipment((s) => toggleArr(s, eq.label))}
+ onPress={() => { setEquipment((s) => toggleArr(s, eq.label)); invalidateSaved(); }}
/>
))}
@@ -144,7 +230,7 @@ export default function WorkoutBuilderScreen({ navigation }) {
key={lv.id}
label={lv.label}
selected={level === lv.id}
- onPress={() => setLevel(level === lv.id ? '' : lv.id)}
+ onPress={() => { setLevel(level === lv.id ? '' : lv.id); invalidateSaved(); }}
/>
))}
@@ -157,12 +243,24 @@ export default function WorkoutBuilderScreen({ navigation }) {
key={d.id}
label={d.label}
selected={duration === d.id}
- onPress={() => setDuration(d.id)}
+ onPress={() => { setDuration(d.id); invalidateSaved(); }}
/>
))}
+
+
@@ -176,14 +274,24 @@ export default function WorkoutBuilderScreen({ navigation }) {
{preview && preview.exercises.length > 0 ? (
-
- {saving ? '...' : 'Sauver'}
+
+
+ {saving ? '...' : savedWorkoutId ? 'Sauvée' : 'Sauver'}
+
(
- {pickExerciseIcon(ex)}
+
{ex.name}
@@ -246,6 +354,23 @@ export default function WorkoutBuilderScreen({ navigation }) {
Lancer la séance
+
+ setInfoModal(null)}
+ />
+
+
);
}
@@ -272,7 +397,7 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#1f1f27',
+ borderBottomColor: Colors.borderSubtle,
},
headerTitle: {
color: Colors.textPrimary,
@@ -352,12 +477,28 @@ const styles = StyleSheet.create({
marginLeft: 6,
},
previewActionDisabled: { opacity: 0.5 },
+ previewActionSaved: {
+ backgroundColor: Colors.primary,
+ borderColor: Colors.primary,
+ },
previewActionText: {
color: Colors.primary,
fontSize: 12,
fontWeight: '700',
marginLeft: 4,
},
+ previewActionTextSaved: { color: '#fff' },
+ nameInput: {
+ backgroundColor: Colors.card,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ color: Colors.textPrimary,
+ fontSize: 14,
+ fontWeight: '600',
+ },
previewEmpty: {
paddingVertical: 18,
alignItems: 'center',
@@ -378,7 +519,7 @@ const styles = StyleSheet.create({
width: 38,
height: 38,
borderRadius: 9,
- backgroundColor: '#0e0e12',
+ backgroundColor: Colors.cardInner,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
@@ -391,6 +532,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
previewItemMuscle: {
+ color: Colors.textSecondary,
fontSize: 12,
marginTop: 2,
},
@@ -409,7 +551,7 @@ const styles = StyleSheet.create({
paddingBottom: 26,
backgroundColor: Colors.background,
borderTopWidth: StyleSheet.hairlineWidth,
- borderTopColor: '#1f1f27',
+ borderTopColor: Colors.borderSubtle,
},
launchBtn: {
flexDirection: 'row',
diff --git a/front/src/screens/Workouts/WorkoutListScreen.js b/front/src/screens/Workouts/WorkoutListScreen.js
index f3af7b6..92c8051 100644
--- a/front/src/screens/Workouts/WorkoutListScreen.js
+++ b/front/src/screens/Workouts/WorkoutListScreen.js
@@ -5,7 +5,6 @@ import {
TouchableOpacity,
StyleSheet,
FlatList,
- Alert,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -19,6 +18,10 @@ import { instantiateSavedWorkout } from '../../services/savedWorkouts.service';
import { useFocusEffect } from '@react-navigation/native';
import TutorialOverlay from '../../components/tutorial/TutorialOverlay';
import { useTutorial, useTutorialTarget } from '../../context/TutorialContext';
+import MultiLobbyModal from '../../components/workouts/MultiLobbyModal';
+import ConfirmModal from '../../components/common/ConfirmModal';
+import InfoModal from '../../components/common/InfoModal';
+import ActionSheetModal from '../../components/common/ActionSheetModal';
// Page d'entrée "Séances".
// Header : titre + 2 icônes (Mes exercices, Créer un exercice).
@@ -32,7 +35,7 @@ function TemplateCard({ template, onPress }) {
activeOpacity={0.85}
>
- {template.icon}
+
{template.name}
@@ -88,13 +91,34 @@ function SavedWorkoutCard({ saved, onPress, onLongPress }) {
const SKIP_CONFIRM_KEY = '@athly_skip_workout_confirm';
-export default function WorkoutListScreen({ navigation }) {
+export default function WorkoutListScreen({ navigation, route }) {
const { loadWorkout } = useWorkoutInProgress();
const { items: savedWorkouts, remove: removeSaved } = useSavedWorkouts();
const [confirmItem, setConfirmItem] = useState(null); // { type: 'template'|'saved', data }
const [dontAsk, setDontAsk] = useState(false);
+ // ─── Lancement en Multi (Section VII) ────────────────────────────────────
+ const [multiLobbyVisible, setMultiLobbyVisible] = useState(false);
+ // Non-null uniquement quand on arrive en rejoignant une invitation reçue
+ // (voir LobbyInviteCheck) — sinon MultiLobbyModal crée un lobby neuf.
+ const [existingLobbyId, setExistingLobbyId] = useState(null);
+
+ // Invitation acceptée depuis la notification (LobbyInviteCheck → navigate) :
+ // ouvre directement MultiLobbyModal en mode "rejoindre" au lieu du parcours
+ // normal (choisir une séance → Lancer en Multi).
+ useEffect(() => {
+ const pendingLobbyId = route?.params?.pendingLobbyId;
+ if (!pendingLobbyId) return;
+ setExistingLobbyId(pendingLobbyId);
+ setMultiLobbyVisible(true);
+ navigation?.setParams({ pendingLobbyId: undefined });
+ }, [route?.params?.pendingLobbyId, navigation]);
+
+ const [deleteSavedTarget, setDeleteSavedTarget] = useState(null);
+ const [actionSheetTarget, setActionSheetTarget] = useState(null);
+ const [errorInfo, setErrorInfo] = useState(null);
+
// ─── Tutorial ─────────────────────────────────────────────────────────────
const { pendingChapterId, activeChapterId, startChapter, registerScrollRef, registerRemeasure } = useTutorial();
const { ref: headerActionsRef, onLayout: onHeaderActionsLayout } = useTutorialTarget('workout_header_actions');
@@ -158,24 +182,54 @@ export default function WorkoutListScreen({ navigation }) {
const handleCancelConfirm = useCallback(() => setConfirmItem(null), []);
- const onLongPressSaved = useCallback((saved) => {
- Alert.alert(
- saved.name,
- 'Que faire avec cette séance ?',
- [
- { text: 'Annuler', style: 'cancel' },
- {
- text: 'Supprimer',
- style: 'destructive',
- onPress: async () => {
- try { await removeSaved(saved.id); } catch (e) {
- Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible');
- }
- },
- },
- ],
- );
- }, [removeSaved]);
+ // "Lancer en Multi" — garde confirmItem pour savoir quelle séance
+ // instancier une fois le lobby actif (voir MultiLobbyModal → onReady).
+ const handleLaunchMulti = useCallback(() => {
+ setExistingLobbyId(null);
+ setMultiLobbyVisible(true);
+ }, []);
+
+ const handleMultiReady = useCallback((lobbyId) => {
+ setMultiLobbyVisible(false);
+ setExistingLobbyId(null);
+ if (!navigation) return;
+ // Cas normal : une séance a été choisie avant "Lancer en Multi". Cas
+ // invitation acceptée (pas de confirmItem, on a rejoint via notification) :
+ // on instancie un template par défaut — chacun gère ses propres
+ // séries/poids côté client, la séance en elle-même n'a pas besoin d'être
+ // identique entre les membres.
+ const workout = !confirmItem
+ ? instantiateWorkout(TEMPLATES[0])
+ : confirmItem.type === 'template'
+ ? instantiateWorkout(confirmItem.data)
+ : instantiateSavedWorkout(confirmItem.data);
+ if (!workout) return;
+ loadWorkout(workout);
+ navigation.navigate('Workout', { workout, lobbyId });
+ setConfirmItem(null);
+ }, [confirmItem, navigation, loadWorkout]);
+
+ const onLongPressSaved = useCallback((saved) => setActionSheetTarget(saved), []);
+
+ // "Modifier" route vers l'écran de création d'ORIGINE de la séance, pour que
+ // l'interface d'édition soit rigoureusement identique à celle de création :
+ // WorkoutBuilder pour toute séance sur-mesure (isManual: false), même une
+ // séance ancienne créée avant l'ajout du champ criteria (elle rouvrira alors
+ // l'écran sur-mesure avec des critères vierges plutôt qu'un éditeur différent) ;
+ // ManualWorkoutCreator uniquement pour les séances explicitement manuelles.
+ const onEditSaved = useCallback((saved) => {
+ if (!navigation) return;
+ const target = saved.isManual ? 'ManualWorkoutCreator' : 'WorkoutBuilder';
+ navigation.navigate(target, { editWorkout: saved });
+ }, [navigation]);
+
+ const confirmDeleteSaved = useCallback(async () => {
+ const saved = deleteSavedTarget;
+ setDeleteSavedTarget(null);
+ try { await removeSaved(saved.id); } catch (e) {
+ setErrorInfo(e && e.message ? e.message : 'Suppression impossible');
+ }
+ }, [deleteSavedTarget, removeSaved]);
const onOpenBuilder = useCallback(() => {
if (navigation) navigation.navigate('WorkoutBuilder');
@@ -333,9 +387,50 @@ export default function WorkoutListScreen({ navigation }) {
Lancer !
+
+
+
+ Lancer en Multi
+
+
+ { setMultiLobbyVisible(false); setExistingLobbyId(null); }}
+ onReady={handleMultiReady}
+ />
+
+ onEditSaved(actionSheetTarget) },
+ { label: 'Supprimer', destructive: true, onPress: () => setDeleteSavedTarget(actionSheetTarget) },
+ ]}
+ onClose={() => setActionSheetTarget(null)}
+ />
+
+ setDeleteSavedTarget(null)}
+ />
+ setErrorInfo(null)}
+ />
);
}
@@ -606,4 +701,11 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '800',
},
+ multiBtn: {
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
+ height: 42, borderRadius: 12, marginTop: 10,
+ borderWidth: 1, borderColor: `${Colors.primary}40`,
+ backgroundColor: `${Colors.primary}12`,
+ },
+ multiBtnTxt: { color: Colors.primary, fontSize: 13, fontWeight: '700' },
});
diff --git a/front/src/screens/Workouts/WorkoutScreen.js b/front/src/screens/Workouts/WorkoutScreen.js
index 95a7689..82f548c 100644
--- a/front/src/screens/Workouts/WorkoutScreen.js
+++ b/front/src/screens/Workouts/WorkoutScreen.js
@@ -5,28 +5,34 @@ import {
TouchableOpacity,
StyleSheet,
ActivityIndicator,
- Alert,
FlatList,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
-import * as Haptics from 'expo-haptics';
+import { haptics } from '../../services';
import { Colors } from '../../constants/theme';
-import useExerciseSorting, { isFiltering } from '../../hooks/useExerciseSorting';
+import { useExerciseSorting, isFiltering } from '../../hooks';
import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext';
import { useWorkoutLogs } from '../../context/WorkoutLogsContext';
-import { useDevSettings } from '../../hooks/useDevSettings';
-import API from '../../api/api';
+import { useDevSettings } from '../../hooks';
+
+import { completeWorkout } from '../../services';
import SortBar from '../../components/workouts/SortBar';
import SupersetGroup from '../../components/workouts/SupersetGroup';
import ExerciseCard from '../../components/cards/ExerciseCard';
+import InlineExerciseBlock from '../../components/workouts/InlineExerciseBlock';
import AddExerciseSheet from '../../components/workouts/AddExerciseSheet';
import WorkoutRecapModal from '../../components/workouts/WorkoutRecapModal';
import ShortSessionWarningModal from '../../components/workouts/ShortSessionWarningModal';
-import QuestToast from '../../components/common/QuestToast';
+import { QuestToast } from '../../components/common';
+import { ConfirmModal } from '../../components/common';
+import LobbyMembersBar from '../../components/workouts/LobbyMembersBar';
+import LobbyWaitingOverlay from '../../components/workouts/LobbyWaitingOverlay';
+import MultiLootModal from '../../components/workouts/MultiLootModal';
+import { getLobby, finishLobby } from '../../services';
const DEFAULT_FILTERS = { muscles: [], levels: [], equipment: [] };
@@ -47,8 +53,35 @@ function estimateMinutes(count) {
export default function WorkoutScreen({ route, navigation }) {
const { state, actions, loadWorkout } = useWorkoutInProgress();
- const { totalXP } = useWorkoutLogs();
+ const { totalXP, addBonusXp } = useWorkoutLogs();
const { bypassAnticheat } = useDevSettings();
+ const [allInOne, setAllInOne] = useState(false);
+
+ // ─── Séance en Multi (Section VII) ───────────────────────────────────────
+ // Chacun gère ses propres séries/poids côté client (résilience réseau) —
+ // le lobby ne sert qu'à afficher les bulles de présence et à synchroniser
+ // la clôture (voir handleTerminate / executeFinalize plus bas).
+ const lobbyId = route?.params?.lobbyId ?? null;
+ const [lobby, setLobby] = useState(null);
+ const [multiWaiting, setMultiWaiting] = useState(false);
+ const [multiLoot, setMultiLoot] = useState(null); // { memberCount, bonusPercent, bonusXp }
+ const multiBonusPendingRef = useRef(null); // { bonusPercent, memberCount } — consommé par executeFinalize
+ const lobbyPollRef = useRef(null);
+
+ // Bulles de présence : poll léger tant que la séance n'est pas terminée.
+ useEffect(() => {
+ if (!lobbyId) return undefined;
+ let cancelled = false;
+ const poll = async () => {
+ try {
+ const res = await getLobby(lobbyId);
+ if (!cancelled) setLobby(res.lobby);
+ } catch (_) {}
+ };
+ poll();
+ const interval = setInterval(poll, 4000);
+ return () => { cancelled = true; clearInterval(interval); };
+ }, [lobbyId]);
const [isFinalizing, setIsFinalizing] = useState(false);
const [elapsed, setElapsed] = useState(0);
@@ -65,6 +98,17 @@ export default function WorkoutScreen({ route, navigation }) {
const [recapVisible, setRecapVisible] = useState(false);
const [recapData, setRecapData] = useState(null);
+ // Popup d'abandon de séance : intercepte toute sortie de l'écran (geste,
+ // bouton retour matériel, action programmatique) tant qu'une progression
+ // réelle existe. `allowExitRef` sert d'échappatoire pour les sorties déjà
+ // voulues par l'utilisateur (fin de séance validée via closeRecap).
+ const [abandonModalVisible, setAbandonModalVisible] = useState(false);
+ const allowExitRef = useRef(false);
+ const pendingNavActionRef = useRef(null);
+
+ // Confirmation de suppression d'un exercice (modale custom, pas d'Alert natif)
+ const [removeConfirm, setRemoveConfirm] = useState(null); // { sourceIndex, name }
+
// Quest toast queue
const [currentToast, setCurrentToast] = useState(null);
const toastQueueRef = useRef([]);
@@ -92,6 +136,37 @@ export default function WorkoutScreen({ route, navigation }) {
const filteredExercises = useExerciseSorting(sourceExercises, filters);
const visibleExercises = filterActive ? filteredExercises : sourceExercises;
+ // Popup d'abandon de séance : dès qu'un exercice a été ajouté, quitter
+ // l'écran (geste retour, bouton matériel Android, navigation programmatique)
+ // annule une vraie progression — on l'intercepte pour confirmer.
+ useEffect(() => {
+ if (!navigation) return undefined;
+ const listener = (e) => {
+ if (allowExitRef.current || sourceExercises.length === 0) return;
+ e.preventDefault();
+ pendingNavActionRef.current = e.data.action;
+ setAbandonModalVisible(true);
+ };
+ const unsubscribe = navigation.addListener('beforeRemove', listener);
+ return unsubscribe;
+ }, [navigation, sourceExercises.length]);
+
+ const confirmAbandon = useCallback(() => {
+ setAbandonModalVisible(false);
+ allowExitRef.current = true;
+ actions.reset();
+ if (pendingNavActionRef.current) {
+ navigation.dispatch(pendingNavActionRef.current);
+ pendingNavActionRef.current = null;
+ }
+ }, [navigation, actions]);
+
+ const cancelAbandon = useCallback(() => {
+ haptics.error();
+ setAbandonModalVisible(false);
+ pendingNavActionRef.current = null;
+ }, []);
+
const displayItems = useMemo(() => {
if (!Array.isArray(visibleExercises) || visibleExercises.length === 0) return [];
@@ -169,15 +244,13 @@ export default function WorkoutScreen({ route, navigation }) {
}, [actions, sheetMode]);
const onRemove = useCallback((sourceIndex, exercise) => {
- Alert.alert(
- 'Supprimer',
- `Retirer "${exercise && exercise.name ? exercise.name : 'cet exercice'}" de la séance ?`,
- [
- { text: 'Annuler', style: 'cancel' },
- { text: 'Supprimer', style: 'destructive', onPress: () => actions.removeExercise(sourceIndex) },
- ],
- );
- }, [actions]);
+ setRemoveConfirm({ sourceIndex, name: exercise?.name || 'cet exercice' });
+ }, []);
+
+ const confirmRemove = useCallback(() => {
+ if (removeConfirm) actions.removeExercise(removeConfirm.sourceIndex);
+ setRemoveConfirm(null);
+ }, [removeConfirm, actions]);
const onToggleSuperset = useCallback((sourceIndex) => {
actions.toggleSupersetWithNext(sourceIndex);
@@ -193,7 +266,7 @@ export default function WorkoutScreen({ route, navigation }) {
const result = await actions.finalize({ notes: state.notes, durationSeconds: elapsed, ...opts });
if (state.id) {
- API.post(`/workouts/${state.id}/complete`).catch(() => {});
+ completeWorkout(state.id).catch(() => {});
}
const builtRecapData = {
@@ -219,6 +292,18 @@ export default function WorkoutScreen({ route, navigation }) {
prevTotalXP,
};
+ // ── Bonus XP Multi (Section VII) ── consommé une seule fois : posé par
+ // handleTerminate dès que le lobby passe 'completed' (voir plus bas).
+ const pendingBonus = multiBonusPendingRef.current;
+ multiBonusPendingRef.current = null;
+ if (pendingBonus && pendingBonus.bonusPercent > 0) {
+ const bonusXp = Math.round(builtRecapData.stats.xpEarned * pendingBonus.bonusPercent);
+ if (bonusXp > 0) {
+ addBonusXp('Bonus Multi', bonusXp).catch(() => {});
+ }
+ setMultiLoot({ memberCount: pendingBonus.memberCount, bonusPercent: pendingBonus.bonusPercent, bonusXp });
+ }
+
const toastItems = [
...(result.completedQuests || []).map((q) => ({ label: q.label, isBonus: false })),
...(result.bonusUnlocked ? [{ label: null, isBonus: true }] : []),
@@ -244,15 +329,14 @@ export default function WorkoutScreen({ route, navigation }) {
} finally {
setIsFinalizing(false);
}
- }, [actions, state.notes, state.id, totalXP, elapsed]);
+ }, [actions, state.notes, state.id, totalXP, elapsed, addBonusXp]);
// ─── TERMINER LA SÉANCE ───────────────────────────────────────────────────
const handleTerminate = useCallback(async () => {
- try {
- await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- } catch (e) {}
+ // Clôture de séance : moment marquant → vibration lourde.
+ haptics.heavy();
- if (isFinalizing || recapVisible) return;
+ if (isFinalizing || recapVisible || multiWaiting) return;
// Anti-cheat 5 min : bloque si < 300s et bypass désactivé
if (elapsed < 300 && !bypassAnticheat) {
@@ -260,8 +344,52 @@ export default function WorkoutScreen({ route, navigation }) {
return;
}
+ // ── Clôture synchrone Multi (Section VII) ── mon statut passe 'finished'.
+ // Si je suis le dernier, le lobby passe 'completed' immédiatement (pas
+ // d'attente). Sinon, écran d'attente bloquant jusqu'à ce que tout le
+ // monde ait fini (polling — pas de websocket dans ce projet).
+ if (lobbyId) {
+ setMultiWaiting(true);
+ try {
+ const res = await finishLobby(lobbyId);
+ setLobby(res.lobby);
+ if (res.completed) {
+ multiBonusPendingRef.current = {
+ bonusPercent: res.lobby.xpBonusPercent,
+ memberCount: res.lobby.memberCount,
+ };
+ setMultiWaiting(false);
+ executeFinalize();
+ } else {
+ lobbyPollRef.current = setInterval(async () => {
+ try {
+ const poll = await getLobby(lobbyId);
+ setLobby(poll.lobby);
+ if (poll.lobby.status === 'completed') {
+ clearInterval(lobbyPollRef.current);
+ multiBonusPendingRef.current = {
+ bonusPercent: poll.lobby.xpBonusPercent,
+ memberCount: poll.lobby.memberCount,
+ };
+ setMultiWaiting(false);
+ executeFinalize();
+ }
+ } catch (_) {}
+ }, 3000);
+ }
+ } catch (_) {
+ // Best-effort : un souci réseau sur le lobby ne doit jamais bloquer
+ // la validation de la propre séance de l'utilisateur.
+ setMultiWaiting(false);
+ executeFinalize();
+ }
+ return;
+ }
+
executeFinalize();
- }, [isFinalizing, recapVisible, elapsed, bypassAnticheat, executeFinalize]);
+ }, [isFinalizing, recapVisible, multiWaiting, elapsed, bypassAnticheat, executeFinalize, lobbyId]);
+
+ useEffect(() => () => { if (lobbyPollRef.current) clearInterval(lobbyPollRef.current); }, []);
// Valider quand même (0 XP, shortSession)
const handleForceFinish = useCallback(() => {
@@ -284,12 +412,13 @@ export default function WorkoutScreen({ route, navigation }) {
}
}, []);
- const closeRecap = useCallback(() => {
- setRecapVisible(false);
- setRecapData(null);
+ const leaveWorkoutScreen = useCallback(() => {
// Reset the workout context so the next session starts clean
actions.reset();
if (navigation) {
+ // Séance déjà validée : cette sortie ne doit jamais déclencher la popup
+ // d'abandon (voir le listener 'beforeRemove' plus haut).
+ allowExitRef.current = true;
// Pop the entire WorkoutStack back to WorkoutList (the root screen),
// then switch to Stats tab. Without popToTop(), WorkoutScreen stays on the
// stack and the user lands back here when they tap "Séances" again.
@@ -298,6 +427,47 @@ export default function WorkoutScreen({ route, navigation }) {
}
}, [navigation, actions]);
+ const closeRecap = useCallback(() => {
+ setRecapVisible(false);
+ setRecapData(null);
+ // Séance en Multi avec butin en attente : le popup d'équipe (MultiLootModal)
+ // prend le relai plutôt que de naviguer immédiatement — voir closeMultiLoot.
+ if (multiLoot) return;
+ leaveWorkoutScreen();
+ }, [multiLoot, leaveWorkoutScreen]);
+
+ const closeMultiLoot = useCallback(() => {
+ setMultiLoot(null);
+ leaveWorkoutScreen();
+ }, [leaveWorkoutScreen]);
+
+ // ── Vue globale : un bloc inline par exercice, pas de navigation ──────────
+ const renderItemAllInOne = ({ item }) => {
+ if (item.type === 'superset') {
+ return (
+
+ {item.exercises.map((ex, idx) => (
+
+ ))}
+
+ );
+ }
+ return (
+
+ );
+ };
+
const renderExercise = (ex, sourceIndex, opts = {}) => {
const isDone = !!(ex && ex.done);
return (
@@ -370,19 +540,43 @@ export default function WorkoutScreen({ route, navigation }) {
+ {/* ── Bulles de présence Multi (Section VII) ── */}
+
+
{/* ── Filtres ── */}
+ {/* ── Toggle vue globale ── */}
+ {sourceExercises.length > 0 && (
+
+ setAllInOne((v) => !v)}
+ activeOpacity={0.8}
+ >
+
+
+ {allInOne ? 'Vue détaillée' : 'Voir tous les exercices'}
+
+
+
+ )}
+
{/* ── Liste des exercices — flex:1 pour occuper tout l'espace disponible ── */}
{displayItems.length > 0 ? (
item.key}
- renderItem={renderItem}
+ renderItem={allInOne ? renderItemAllInOne : renderItem}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
+ keyboardShouldPersistTaps="handled"
/>
) : (
@@ -450,6 +644,39 @@ export default function WorkoutScreen({ route, navigation }) {
elapsedSeconds={elapsed}
/>
+
+
+ setRemoveConfirm(null)}
+ />
+
+
+
+
+
);
}
@@ -592,4 +819,34 @@ const styles = StyleSheet.create({
fontWeight: '900',
letterSpacing: 1.2,
},
+
+ // ── Bascule vue globale ────────────────────────────────────────────────
+ viewToggleBar: {
+ paddingHorizontal: 20,
+ paddingVertical: 6,
+ alignItems: 'flex-end',
+ },
+ viewToggleBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: Colors.borderDim,
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ },
+ viewToggleBtnActive: {
+ borderColor: 'rgba(110,106,240,0.45)',
+ backgroundColor: 'rgba(110,106,240,0.09)',
+ },
+ viewToggleText: {
+ color: Colors.textMuted,
+ fontSize: 12,
+ fontWeight: '700',
+ },
+ viewToggleTextActive: {
+ color: Colors.secondaryAccent,
+ },
});
diff --git a/front/src/services/__tests__/xpSync.service.test.js b/front/src/services/__tests__/xpSync.service.test.js
new file mode 100644
index 0000000..6419161
--- /dev/null
+++ b/front/src/services/__tests__/xpSync.service.test.js
@@ -0,0 +1,138 @@
+'use strict';
+
+// Mocks manuels : ce fichier tourne sous testEnvironment 'node' (voir
+// jest.config.js — pas d'environnement React Native), donc AsyncStorage et
+// l'instance axios (../api/api) doivent être entièrement remplacés, jamais
+// réellement importés (ils dépendent de bindings natifs indisponibles ici).
+const mockGetItem = jest.fn();
+const mockSetItem = jest.fn().mockResolvedValue(undefined);
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ getItem: (...args) => mockGetItem(...args),
+ setItem: (...args) => mockSetItem(...args),
+}));
+
+const mockPost = jest.fn();
+jest.mock('../../api/api', () => ({
+ __esModule: true,
+ default: { post: (...args) => mockPost(...args) },
+}));
+
+const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1';
+
+describe('xpSync.service — synchronisation XP locale → backend (Section X)', () => {
+ let syncXp;
+ let retryPendingXpSync;
+
+ beforeEach(() => {
+ // lastSyncedXp est un state de MODULE (mémoire) — reset complet entre
+ // chaque test pour ne jamais laisser un test polluer le suivant.
+ jest.resetModules();
+ jest.clearAllMocks();
+ mockSetItem.mockResolvedValue(undefined);
+ ({ syncXp, retryPendingXpSync } = require('../xpSync.service'));
+ });
+
+ describe('syncXp — mode connecté', () => {
+ it('✅ Envoie le total XP arrondi et marque isXpSynced à true', async () => {
+ mockPost.mockResolvedValue({ data: { success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] } });
+
+ const result = await syncXp(1799.6);
+
+ expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 1800 });
+ expect(result).toEqual({ success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] });
+ expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'true');
+ });
+
+ it("✅ Renvoie les titres nouvellement débloqués par ce gain d'XP", async () => {
+ mockPost.mockResolvedValue({ data: { success: true, level: 50, xp: 200000, rank: 'Compétiteur', newlyUnlockedTitles: ['PERFORM_LEVEL_50'] } });
+
+ const result = await syncXp(200000);
+
+ expect(result.newlyUnlockedTitles).toContain('PERFORM_LEVEL_50');
+ });
+
+ it('✅ Ne rappelle pas l\'API si la valeur est identique au dernier sync réussi (dédup mémoire)', async () => {
+ mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } });
+
+ await syncXp(500);
+ await syncXp(500);
+
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('✅ Rappelle bien l\'API si la valeur a changé depuis le dernier sync', async () => {
+ mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } });
+ await syncXp(500);
+
+ mockPost.mockResolvedValue({ data: { success: true, level: 6, xp: 600, rank: 'Novice', newlyUnlockedTitles: [] } });
+ await syncXp(600);
+
+ expect(mockPost).toHaveBeenCalledTimes(2);
+ });
+
+ it('❌ xp négatif ou non-fini : ne fait aucun appel réseau', async () => {
+ await syncXp(-5);
+ await syncXp(NaN);
+ await syncXp(undefined);
+
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('syncXp — mode déconnecté (offline)', () => {
+ it("✅ Échec réseau : renvoie null et marque isXpSynced à false (sans lever d'exception)", async () => {
+ mockPost.mockRejectedValue(new Error('Network Error'));
+
+ const result = await syncXp(1000);
+
+ expect(result).toBeNull();
+ expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'false');
+ });
+
+ it("✅ Un échec n'active pas la dédup mémoire — le prochain appel retente vraiment", async () => {
+ mockPost.mockRejectedValueOnce(new Error('Network Error'));
+ mockPost.mockResolvedValueOnce({ data: { success: true, level: 3, xp: 300, rank: 'Novice', newlyUnlockedTitles: [] } });
+
+ const first = await syncXp(300);
+ const second = await syncXp(300);
+
+ expect(first).toBeNull();
+ expect(second).not.toBeNull();
+ expect(mockPost).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('retryPendingXpSync — retente au retour réseau / prochain démarrage', () => {
+ it('✅ Retente si isXpSynced === "false"', async () => {
+ mockGetItem.mockResolvedValue('false');
+ mockPost.mockResolvedValue({ data: { success: true, level: 4, xp: 400, rank: 'Novice', newlyUnlockedTitles: [] } });
+
+ await retryPendingXpSync(400);
+
+ expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 400 });
+ });
+
+ it('❌ Ne retente pas si isXpSynced === "true" (déjà à jour)', async () => {
+ mockGetItem.mockResolvedValue('true');
+
+ await retryPendingXpSync(400);
+
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it("❌ Ne retente pas si le flag n'a jamais été écrit (premier lancement)", async () => {
+ mockGetItem.mockResolvedValue(null);
+
+ await retryPendingXpSync(400);
+
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('✅ Best-effort : une erreur AsyncStorage ne lève jamais', async () => {
+ mockGetItem.mockRejectedValue(new Error('storage unavailable'));
+
+ await expect(retryPendingXpSync(400)).resolves.toBeUndefined();
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/front/src/services/auth.service.js b/front/src/services/auth.service.js
index a49dbab..2b2cc61 100644
--- a/front/src/services/auth.service.js
+++ b/front/src/services/auth.service.js
@@ -13,6 +13,14 @@ export async function register(data) {
return res.data;
}
+// Connexion en un clic via Google — idToken obtenu côté client (expo-auth-session),
+// vérifié côté serveur (voir back/services/auth.service.js → googleLogin).
+export async function googleLogin(idToken) {
+ const res = await API.post('/auth/google', { idToken });
+ if (res.data?.token) await saveToken(res.data.token);
+ return res.data;
+}
+
export async function logout() {
await removeToken();
}
diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js
new file mode 100644
index 0000000..b74bbd5
--- /dev/null
+++ b/front/src/services/debug.service.js
@@ -0,0 +1,111 @@
+import API from '../api/api';
+
+// Outil de test God Mode → backend (voir back/controllers/debug.controller.js).
+// Aligne xp/level/rank backend sur `level` — jamais exposé en production
+// (l'endpoint renvoie 404 côté serveur si NODE_ENV=production).
+export async function syncBackendLevel(level) {
+ const res = await API.post('/debug/sync-level', { level });
+ return res.data;
+}
+
+// Crédite `amount` CHEST_KEY dans l'inventaire backend (défaut 1).
+export async function giveChests(amount = 1) {
+ const res = await API.post('/debug/godmode/give-chests', { amount });
+ return res.data;
+}
+
+// Génère (ou régénère) un faux réseau social : 2 amis acceptés + 1 demande
+// en attente reçue, pour tester Classement/Groupe/Accepter-Refuser.
+export async function generateMockSocial() {
+ const res = await API.post('/debug/godmode/mock-social');
+ return res.data;
+}
+
+// Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques
+// Uniques réclamables) dans l'inventaire, pour tout tester en un clic.
+export async function giveAllItems() {
+ const res = await API.post('/debug/godmode/give-all-items');
+ return res.data;
+}
+
+// Simule `amount` coffres ouverts (sans vraies ouvertures) — débloque les
+// trophées gradués CHEST_1…CHEST_200 et le thème Rouge Sang au palier 100.
+export async function simulateChestsOpened(amount = 1) {
+ const res = await API.post('/debug/godmode/simulate-chests-opened', { amount });
+ return res.data;
+}
+
+// Crée un filleul factice (referredBy) pour débloquer FIRST_REFERRAL.
+export async function simulateReferral() {
+ const res = await API.post('/debug/godmode/simulate-referral');
+ return res.data;
+}
+
+// Force la date de naissance à aujourd'hui pour débloquer BIRTHDAY_SET
+// et BIRTHDAY_CELEBRATED sans attendre le vrai jour J.
+export async function simulateBirthday() {
+ const res = await API.post('/debug/godmode/simulate-birthday');
+ return res.data;
+}
+
+// ─── Vague 1 : groupe, météo, activité, Hall of Shame, secouer ───────────────
+
+// Crée (ou régénère) un groupe de streak avec 3 coéquipiers factices, un par
+// statut de Météo des séances (Prêt/Actif/Validé) — teste weatherStatus, le
+// multiplicateur de groupe et le bouton Secouer sans second appareil.
+export async function simulateGroup() {
+ const res = await API.post('/debug/godmode/simulate-group');
+ return res.data;
+}
+
+// Publie un ActivityEvent factice au nom d'un coéquipier (jamais soi-même) —
+// teste ActivityFeedModal et les réactions. type: 'pr_broken' | 'chest_legendary'.
+export async function simulateActivityEvent(type) {
+ const res = await API.post('/debug/godmode/simulate-activity-event', type ? { type } : {});
+ return res.data;
+}
+
+// Recule lastValidatedDate du groupe pour déclencher le Hall of Shame au
+// prochain chargement de l'onglet Groupe.
+export async function simulateStreakBreak() {
+ const res = await API.post('/debug/godmode/simulate-streak-break');
+ return res.data;
+}
+
+// Envoie une vraie notification push au token Expo de l'utilisateur connecté
+// (même texte troll que le vrai bouton Secouer) — vérifie l'infra push de
+// bout en bout sans second compte/appareil.
+export async function simulateShakeSelf() {
+ const res = await API.post('/debug/godmode/simulate-shake-self');
+ return res.data;
+}
+
+// ─── Vague 2 : tag Discord, ajout d'ami ───────────────────────────────────────
+
+// Crée un compte de test PAS déjà ami (contrairement à generateMockSocial) —
+// seul moyen de tester en solo le parcours complet "Ajouter un ami" :
+// recherche par tag exact, carte Preview, envoi réel de la demande.
+export async function simulateSearchableFriend() {
+ const res = await API.post('/debug/godmode/simulate-searchable-friend');
+ return res.data;
+}
+
+// ─── Vague 3 : Lobby Multi ─────────────────────────────────────────────────────
+
+// Crée un lobby Multi avec un coéquipier factice (isTestBot) et envoie une
+// vraie notification push d'invitation à l'utilisateur connecté — teste le
+// parcours complet (popup d'invitation, rejoindre, prêt, séance, bonus de
+// groupe) sans second appareil, le bot suivant automatiquement tes statuts.
+export async function simulateLobbyInvite() {
+ const res = await API.post('/debug/godmode/simulate-lobby-invite');
+ return res.data;
+}
+
+// ─── Titres (Section X) ─────────────────────────────────────────────────────
+
+// Débloque directement tous les titres du catalogue (sans passer par leurs
+// conditions réelles) — teste le sélecteur de titres en un clic.
+export async function giveAllTitles() {
+ const res = await API.post('/debug/godmode/give-all-titles');
+ return res.data;
+}
diff --git a/front/src/services/exercise.service.js b/front/src/services/exercise.service.js
deleted file mode 100644
index 39400d8..0000000
--- a/front/src/services/exercise.service.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import API from "../api/api";
-
-export function getExerciseRecords(workoutId) {
- return API.get(`/exercise/workout/${workoutId}`);
-}
-
-export function addExerciseRecord(data) {
- return API.post("/exercise", data);
-}
\ No newline at end of file
diff --git a/front/src/services/haptics.service.js b/front/src/services/haptics.service.js
new file mode 100644
index 0000000..a74d11b
--- /dev/null
+++ b/front/src/services/haptics.service.js
@@ -0,0 +1,43 @@
+import * as Haptics from 'expo-haptics';
+
+// ─── Service Haptique centralisé (Section X) ───────────────────────────────────
+// Point d'entrée unique pour tous les retours physiques de l'app — évite de
+// ré-écrire `try { Haptics.xxxAsync(...) } catch {}` à chaque appel. Web n'a
+// pas de vibreur (expo-haptics y est un no-op silencieux), donc aucune garde
+// de plateforme n'est nécessaire ici.
+//
+// Intensités volontairement poussées au maximum de ce qu'expose expo-haptics
+// (retour jugé trop discret en usage réel) — pas de Light/selectionAsync seul,
+// qui passent quasi inaperçus sur la plupart des téléphones :
+// - success() : action réussie (série cochée, clic d'action, bouton rapide) —
+// impact Medium, net et immédiat.
+// - heavy() : moment marquant (clôture de séance, level-up, ouverture de
+// coffre, déblocage de titre) — double impact Heavy.
+// - error() : erreur / avertissement (pseudo injurieux, annulation d'abandon) —
+// impact Heavy + triple vibration d'avertissement.
+// - selection(): feedback de sélection neutre (toggle, tap sur une liste) —
+// garde le primitif le plus léger, utilisé avec parcimonie.
+
+async function success() {
+ try { await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } catch (_) {}
+}
+
+async function heavy() {
+ try {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
+ setTimeout(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {}); }, 90);
+ } catch (_) {}
+}
+
+async function error() {
+ try {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ } catch (_) {}
+}
+
+async function selection() {
+ try { await Haptics.selectionAsync(); } catch (_) {}
+}
+
+export const haptics = { success, heavy, error, selection };
diff --git a/front/src/services/index.js b/front/src/services/index.js
new file mode 100644
index 0000000..b08f3b8
--- /dev/null
+++ b/front/src/services/index.js
@@ -0,0 +1,25 @@
+// ─── Barrel des services ──────────────────────────────────────────────────────
+// Point d'entrée unique : `import { getFriendsList, haptics } from '../services';`
+// Tous les services n'exportent que des fonctions/constantes nommées, sans
+// collision de noms (vérifié) — l'agrégation en `export *` est donc sûre.
+// Règle : ne jamais importer ce barrel DEPUIS un fichier de ce dossier
+// (import direct entre services) pour exclure tout cycle de dépendances.
+
+export * from './auth.service';
+export * from './customExercises.service';
+export * from './debug.service';
+export * from './haptics.service';
+export * from './inventory.service';
+export * from './lobby.service';
+export * from './notificationService';
+export * from './onboarding.service';
+export * from './profile.service';
+export * from './quest.service';
+export * from './reward.service';
+export * from './savedWorkouts.service';
+export * from './social.service';
+export * from './stats.service';
+export * from './title.service';
+export * from './weight.service';
+export * from './workouts.service';
+export * from './xpSync.service';
diff --git a/front/src/services/inventory.service.js b/front/src/services/inventory.service.js
new file mode 100644
index 0000000..bd9a05c
--- /dev/null
+++ b/front/src/services/inventory.service.js
@@ -0,0 +1,86 @@
+import API from '../api/api';
+import { Colors } from '../constants/theme';
+
+// ─── Inventaire & Coffres (Brique II) ─────────────────────────────────────────
+
+// Catalogue d'affichage — miroir du backend (User.js enum + chest.service.js).
+// Source de vérité des effets : le backend. Ceci ne sert qu'à l'UI.
+// icon = nom Ionicons (pas d'emoji — cohérence avec le reste du design system).
+export const ITEM_CATALOG = {
+ ENERGY_DRINK: {
+ name: 'Boisson Énergisante', icon: 'flash', rarity: 'common',
+ description: '+150 XP instantané', usable: true,
+ },
+ STREAK_FREEZE: {
+ name: 'Gel de Streak', icon: 'snow', rarity: 'rare',
+ description: 'Charge 1 gel, sauve ta streak en cas de jour manqué', usable: true,
+ },
+ DOUBLE_XP: {
+ name: 'Boost Double XP', icon: 'flash-outline', rarity: 'rare',
+ description: 'Bonus Double XP sur tes séances', usable: true,
+ },
+ SUPER_STREAK_FREEZE: {
+ name: 'Super-Gel de Streak', icon: 'snow-outline', rarity: 'epic',
+ description: 'Recharge instantanément tes 3 slots de gels', usable: true,
+ },
+ TRIPLE_XP: {
+ name: 'Boost Triple XP', icon: 'flame', rarity: 'epic',
+ description: 'Bonus Triple XP sur tes séances', usable: true,
+ },
+ LEVEL_COUPON: {
+ name: 'Coupon de Niveau', icon: 'ticket', rarity: 'legendary',
+ description: 'Passe instantanément au niveau supérieur', usable: true,
+ },
+ QUINTUPLE_XP: {
+ name: 'Boost Quintuple XP', icon: 'rocket', rarity: 'legendary',
+ description: 'Bonus Quintuple XP sur tes séances', usable: true,
+ },
+ CHEST_KEY: {
+ name: 'Coffre', icon: 'cube', rarity: 'common',
+ description: 'Un coffre à ouvrir, que contient-il ?', usable: false,
+ },
+ PROFILE_FRAME_BLOOD_BOND: {
+ name: 'Cadre "Lien de Sang"', icon: 'shield', rarity: 'unique',
+ description: "Cosmétique Unique (niveau d'amitié 5). Introuvable en coffre.",
+ usable: false, claimable: true,
+ },
+ FRAME_COLOR_BLOOD_SANG: {
+ name: 'Couleur "Rouge Sang"', icon: 'color-palette', rarity: 'unique',
+ description: "Cosmétique Unique (streak de groupe 30 jours à 5). Introuvable en coffre.",
+ usable: false, claimable: true,
+ },
+ THEME_UNLOCK_BLOOD_SANG: {
+ name: 'Thème "Rouge Sang"', icon: 'color-wand', rarity: 'unique',
+ description: "Cosmétique Unique (100 coffres ouverts). Introuvable en coffre.",
+ usable: false, claimable: true,
+ },
+};
+
+// Couleurs par rareté — alignées sur la palette du jeu (theme.js).
+// unique = Rouge Sang premium (voir Colors.uniqueBlood*), pas or : distingue
+// visuellement le palier ultime de tout ce qui est légendaire/doré.
+export const RARITY_META = {
+ common: { label: 'Commun', color: '#9AA0AE' },
+ rare: { label: 'Rare', color: '#3B82F6' },
+ epic: { label: 'Épique', color: '#A855F7' },
+ legendary: { label: 'Légendaire', color: '#FE7439' },
+ unique: { label: 'Unique', color: Colors.uniqueBlood, gradient: [Colors.uniqueBloodBright, Colors.uniqueBlood, Colors.uniqueBloodDeep], glow: Colors.uniqueBloodGlow },
+};
+
+export async function openChest() {
+ const res = await API.post('/inventory/chest/open');
+ return res.data;
+}
+
+export async function useItem(itemType) {
+ const res = await API.post('/inventory/item/use', { itemType });
+ return res.data;
+}
+
+// Réclame un cosmétique Unique en attente (consomme l'item d'inventaire,
+// débloque définitivement le cosmétique et l'équipe automatiquement côté
+// backend). Voir back/controllers/inventory.controller.js → claimUniqueItem.
+export async function claimUniqueItem(itemType) {
+ const res = await API.post('/inventory/claim', { itemType });
+ return res.data;
+}
diff --git a/front/src/services/lobby.service.js b/front/src/services/lobby.service.js
new file mode 100644
index 0000000..fbda3e8
--- /dev/null
+++ b/front/src/services/lobby.service.js
@@ -0,0 +1,38 @@
+import API from '../api/api';
+
+// ─── Lobby Multi (Section VII) ────────────────────────────────────────────────
+
+export async function createLobby() {
+ const res = await API.post('/lobby/create');
+ return res.data;
+}
+
+export async function getLobby(lobbyId) {
+ const res = await API.get(`/lobby/${lobbyId}`);
+ return res.data;
+}
+
+export async function inviteToLobby(lobbyId, friendId) {
+ const res = await API.post(`/lobby/${lobbyId}/invite`, { friendId });
+ return res.data;
+}
+
+export async function joinLobby(lobbyId) {
+ const res = await API.post(`/lobby/${lobbyId}/join`);
+ return res.data;
+}
+
+export async function readyLobby(lobbyId) {
+ const res = await API.post(`/lobby/${lobbyId}/ready`);
+ return res.data;
+}
+
+export async function unreadyLobby(lobbyId) {
+ const res = await API.post(`/lobby/${lobbyId}/unready`);
+ return res.data;
+}
+
+export async function finishLobby(lobbyId) {
+ const res = await API.post(`/lobby/${lobbyId}/finish`);
+ return res.data;
+}
diff --git a/front/src/services/notificationService.js b/front/src/services/notificationService.js
index dfd7a7c..df28eb6 100644
--- a/front/src/services/notificationService.js
+++ b/front/src/services/notificationService.js
@@ -1,10 +1,27 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
+import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
-const DAILY_NOTIF_ID_KEY = 'athly:notif:daily_id:v1';
-const CHANNEL_ORANGE_ID = 'streak-orange';
-const CHANNEL_VIOLET_ID = 'streak-purple';
+const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2';
+// Titre du DERNIER rappel quotidien généré (persisté en cache local) — permet
+// à pickDailyOccurrence d'éviter une répétition même quand la planification
+// est régénérée (ensureDailyRemindersScheduled), sans quoi le jour 1 d'un
+// nouveau lot pourrait reproduire le titre du dernier jour du lot précédent.
+const LAST_NOTIF_TITLE_KEY = 'athly:notif:last_title:v1';
+const CHANNEL_ORANGE_ID = 'streak-orange';
+const CHANNEL_VIOLET_ID = 'streak-purple';
+const CHANNEL_BIRTHDAY_ID = 'athly-birthday';
+const CHANNEL_EVENTS_ID = 'athly-events';
+
+// Nombre de jours pour lesquels on pré-planifie un rappel quotidien.
+// Chaque jour a son propre trigger DATE avec un contenu tiré indépendamment
+// (contrairement à un trigger DAILY qui réutilise le même contenu figé à l'infini).
+// iOS plafonne à 64 notifications locales en attente : on garde une marge pour
+// les notifications contextuelles (anniversaire, coffre, ami, secousse).
+const DAILY_BATCH_SIZE = 21;
+// Seuil sous lequel on considère qu'il faut reconstituer le stock de rappels.
+const REFILL_THRESHOLD = 5;
const MESSAGES_ORANGE = [
{ title: "La streak t'attend 🔥", body: "Tu es à une séance d'une meilleure version de toi. Allez, lance-toi !" },
@@ -53,6 +70,18 @@ export async function setupNotificationChannels() {
lightColor: '#8B5CF6',
vibrationPattern: [0, 400, 200, 400],
}),
+ Notifications.setNotificationChannelAsync(CHANNEL_BIRTHDAY_ID, {
+ name: 'Anniversaire',
+ importance: Notifications.AndroidImportance.HIGH,
+ lightColor: '#FFD700',
+ vibrationPattern: [0, 300, 150, 300, 150, 300],
+ }),
+ Notifications.setNotificationChannelAsync(CHANNEL_EVENTS_ID, {
+ name: 'Événements Athly',
+ importance: Notifications.AndroidImportance.HIGH,
+ lightColor: '#22D3EE',
+ vibrationPattern: [0, 250, 250, 250],
+ }),
]);
}
@@ -62,10 +91,48 @@ export async function requestNotificationPermissions() {
return status === 'granted';
}
+/**
+ * Récupère le token Expo Push de l'appareil courant, en demandant la
+ * permission si besoin. Retourne null sur web (non supporté), en cas de
+ * permission refusée, ou si l'obtention échoue (jamais d'exception qui
+ * remonterait — un push cross-device est un bonus UX, pas une dépendance
+ * bloquante pour le reste de l'app).
+ */
+export async function getExpoPushToken() {
+ if (Platform.OS === 'web') return null;
+ try {
+ const { status: existing } = await Notifications.getPermissionsAsync();
+ let status = existing;
+ if (status !== 'granted') {
+ ({ status } = await Notifications.requestPermissionsAsync());
+ }
+ if (status !== 'granted') return null;
+
+ const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
+ const { data } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined);
+ return data ?? null;
+ } catch (_) {
+ return null;
+ }
+}
+
function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
+// Tire un {channel, msg} au hasard (50/50 sur le canal) en évitant de reproduire
+// le même titre que `previous`, pour ne jamais avoir deux jours consécutifs identiques.
+function pickDailyOccurrence(previous) {
+ let attempt;
+ for (let i = 0; i < 5; i++) {
+ const useOrange = Math.random() < 0.5;
+ const msg = pickRandom(useOrange ? MESSAGES_ORANGE : MESSAGES_VIOLET);
+ attempt = { channelId: useOrange ? CHANNEL_ORANGE_ID : CHANNEL_VIOLET_ID, msg };
+ if (!previous || attempt.msg.title !== previous.msg.title) return attempt;
+ }
+ return attempt;
+}
+
export async function fireTestNotification(type) {
if (Platform.OS === 'web') return;
const isOrange = type === 'orange';
@@ -86,37 +153,150 @@ export async function fireTestNotification(type) {
});
}
-export async function scheduleDailyReminder(hour = 18, minute = 0) {
+/**
+ * Planifie une série de rappels quotidiens (un par jour, `count` jours),
+ * chacun avec un canal et un message tirés indépendamment. Contrairement à un
+ * trigger DAILY unique (contenu figé pour toujours), chaque occurrence a son
+ * propre contenu -> plus d'effet "perroquet".
+ */
+export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY_BATCH_SIZE) {
if (Platform.OS === 'web') return null;
+
+ // Nettoie systématiquement toute planification précédente avant d'en créer
+ // une nouvelle, pour éviter l'accumulation de rappels dupliqués en arrière-plan.
await cancelDailyReminder();
- const useOrange = Math.random() < 0.5;
- const msg = pickRandom(useOrange ? MESSAGES_ORANGE : MESSAGES_VIOLET);
- const id = await Notifications.scheduleNotificationAsync({
- content: {
- title: msg.title,
- body: msg.body,
- sound: true,
- ...(Platform.OS === 'android' && {
- channelId: useOrange ? CHANNEL_ORANGE_ID : CHANNEL_VIOLET_ID,
- }),
- },
- trigger: {
- type: Notifications.SchedulableTriggerInputTypes.DAILY,
- hour,
- minute,
- },
- });
- try { await AsyncStorage.setItem(DAILY_NOTIF_ID_KEY, id); } catch (_) {}
- return id;
+
+ // Anti-répétition inter-lots : reprend le dernier titre généré (même après
+ // régénération du stock) pour ne jamais enchaîner deux jours identiques.
+ let lastTitle = null;
+ try { lastTitle = await AsyncStorage.getItem(LAST_NOTIF_TITLE_KEY); } catch (_) {}
+
+ const now = new Date();
+ const ids = [];
+ let previous = lastTitle ? { msg: { title: lastTitle } } : null;
+
+ for (let dayOffset = 0; dayOffset < count; dayOffset++) {
+ const date = new Date(now);
+ date.setDate(date.getDate() + dayOffset);
+ date.setHours(hour, minute, 0, 0);
+ // Si l'heure du jour 0 est déjà passée, on démarre à demain.
+ if (date <= now) {
+ date.setDate(date.getDate() + 1);
+ }
+
+ const occurrence = pickDailyOccurrence(previous);
+ previous = occurrence;
+
+ const id = await Notifications.scheduleNotificationAsync({
+ content: {
+ title: occurrence.msg.title,
+ body: occurrence.msg.body,
+ sound: true,
+ data: { type: 'daily_reminder' },
+ ...(Platform.OS === 'android' && { channelId: occurrence.channelId }),
+ },
+ trigger: {
+ type: Notifications.SchedulableTriggerInputTypes.DATE,
+ date,
+ },
+ });
+ ids.push(id);
+ }
+
+ try {
+ await AsyncStorage.setItem(DAILY_NOTIF_IDS_KEY, JSON.stringify({ hour, minute, ids }));
+ if (previous?.msg?.title) {
+ await AsyncStorage.setItem(LAST_NOTIF_TITLE_KEY, previous.msg.title);
+ }
+ } catch (_) {}
+
+ return ids;
}
-export async function cancelDailyReminder() {
+/**
+ * À appeler au lancement de l'app (ou au retour au premier plan) : si le stock
+ * de rappels pré-planifiés commence à manquer, on le reconstitue. Le
+ * cancel-puis-replanifie de `scheduleDailyReminder` garantit qu'on ne double
+ * jamais les notifications en attente.
+ */
+export async function ensureDailyRemindersScheduled(hour = 18, minute = 0) {
if (Platform.OS === 'web') return;
try {
- const id = await AsyncStorage.getItem(DAILY_NOTIF_ID_KEY);
- if (id) {
- await Notifications.cancelScheduledNotificationAsync(id);
- await AsyncStorage.removeItem(DAILY_NOTIF_ID_KEY);
+ const raw = await AsyncStorage.getItem(DAILY_NOTIF_IDS_KEY);
+ const stored = raw ? JSON.parse(raw) : null;
+ const remaining = stored?.ids?.length ?? 0;
+ const sameSlot = stored && stored.hour === hour && stored.minute === minute;
+
+ if (!sameSlot || remaining < REFILL_THRESHOLD) {
+ await scheduleDailyReminder(hour, minute);
}
+ } catch (_) {
+ await scheduleDailyReminder(hour, minute);
+ }
+}
+
+export async function cancelDailyReminder() {
+ if (Platform.OS === 'web') return;
+ try {
+ const raw = await AsyncStorage.getItem(DAILY_NOTIF_IDS_KEY);
+ const stored = raw ? JSON.parse(raw) : null;
+ const ids = stored?.ids ?? [];
+ await Promise.all(ids.map((id) => Notifications.cancelScheduledNotificationAsync(id)));
+ await AsyncStorage.removeItem(DAILY_NOTIF_IDS_KEY);
} catch (_) {}
}
+
+// ─── Notifications contextuelles V2 ───────────────────────────────────────
+// Déclenchées ponctuellement par l'app quand elle détecte l'événement
+// correspondant (réponse API, websocket, etc.) — elles ne touchent pas au
+// stock des rappels quotidiens gérés ci-dessus.
+
+async function fireImmediate({ title, body, channelId, data }) {
+ if (Platform.OS === 'web') return null;
+ return Notifications.scheduleNotificationAsync({
+ content: {
+ title,
+ body,
+ sound: true,
+ data,
+ ...(Platform.OS === 'android' && { channelId }),
+ },
+ trigger: null, // délivrance immédiate
+ });
+}
+
+export async function notifyBirthday(firstName) {
+ return fireImmediate({
+ title: `🎂 Joyeux Anniversaire ${firstName} !`,
+ body: 'Ton coffre et ton trophée t\'attendent !',
+ channelId: CHANNEL_BIRTHDAY_ID,
+ data: { type: 'birthday' },
+ });
+}
+
+export async function notifyChestAvailable() {
+ return fireImmediate({
+ title: '📦 Nouvel effort récompensé !',
+ body: 'Un coffre est prêt à être ouvert.',
+ channelId: CHANNEL_EVENTS_ID,
+ data: { type: 'chest_available' },
+ });
+}
+
+export async function notifyFriendInvite(pseudo) {
+ return fireImmediate({
+ title: '⚡ Nouvelle invitation',
+ body: `${pseudo} veut devenir ton ami sur Athly. Accepte l'invitation !`,
+ channelId: CHANNEL_EVENTS_ID,
+ data: { type: 'friend_invite', pseudo },
+ });
+}
+
+export async function notifyShake(pseudo) {
+ return fireImmediate({
+ title: '🚨 BOUGE-TOI !',
+ body: `${pseudo} t'a secoué. Ne casse pas la Streak du groupe !`,
+ channelId: CHANNEL_EVENTS_ID,
+ data: { type: 'shake', pseudo },
+ });
+}
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;
+}
diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js
new file mode 100644
index 0000000..422ac04
--- /dev/null
+++ b/front/src/services/profile.service.js
@@ -0,0 +1,37 @@
+import API from '../api/api';
+
+// Met à jour les informations du profil (nom, mensurations, objectif, rythme…).
+// Renvoie la réponse brute — l'appelant gère le refetch du UserContext.
+export async function updateMyProfile(payload) {
+ const res = await API.put('/users/me', payload);
+ return res.data;
+}
+
+// Synchronise le cadre de profil équipé (forme + couleur) vers le backend,
+// pour qu'il soit visible sur le profil public par les amis (useAvatarFrame
+// reste la source de vérité locale pour l'affichage instantané côté soi-même).
+export async function updateEquippedFrame(shapeId, colorId) {
+ const res = await API.put('/users/me/frame', { shapeId, colorId });
+ return res.data;
+}
+
+// Met à jour la vitrine de trophées mis en avant sur le profil public (max 3).
+export async function updateShowcase(achievementIds) {
+ const res = await API.put('/users/me/showcase', { achievementIds });
+ return res.data;
+}
+
+// Met à jour les records d'exercices mis en avant sur le profil (max 6) —
+// affichés identiquement sur son propre profil et le profil vu par les amis.
+export async function updateRecordsShowcase(exerciseNames) {
+ const res = await API.put('/users/me/records-showcase', { exerciseNames });
+ return res.data;
+}
+
+// Enregistre (ou efface, si null) le token Expo Push de l'appareil courant —
+// nécessaire pour recevoir les notifications réellement envoyées par un
+// autre appareil (bouton Secouer, réactions du flux d'activité...).
+export async function registerPushToken(pushToken) {
+ const res = await API.put('/users/me/push-token', { pushToken });
+ return res.data;
+}
diff --git a/front/src/services/reward.service.js b/front/src/services/reward.service.js
new file mode 100644
index 0000000..6a6f5b4
--- /dev/null
+++ b/front/src/services/reward.service.js
@@ -0,0 +1,27 @@
+import API from '../api/api';
+
+// Enregistre la date de naissance (une seule fois — verrouillée côté backend).
+export async function setBirthdate(dateISO) {
+ const res = await API.post('/rewards/birthdate', { birthdate: dateISO });
+ return res.data;
+}
+
+// À appeler au login / au chargement de l'app : vérifie si c'est le jour J
+// et accorde le cadeau d'anniversaire (une fois par an).
+export async function checkBirthday() {
+ const res = await API.post('/rewards/birthday/check');
+ return res.data;
+}
+
+export async function getAchievements() {
+ const res = await API.get('/rewards/achievements');
+ return res.data;
+}
+
+// Synchronise les trophées du catalogue LOCAL (V1, évalués depuis les logs de
+// séances AsyncStorage) débloqués côté client, pour qu'ils apparaissent sur
+// le profil public consulté par les amis. Additif — jamais destructeur.
+export async function syncLocalAchievements(ids) {
+ const res = await API.put('/rewards/achievements/sync', { ids });
+ return res.data;
+}
diff --git a/front/src/services/savedWorkouts.service.js b/front/src/services/savedWorkouts.service.js
index d83a4dd..ee1713f 100644
--- a/front/src/services/savedWorkouts.service.js
+++ b/front/src/services/savedWorkouts.service.js
@@ -2,7 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
// CRUD des séances sauvegardées (favorites / réutilisables).
// Schéma :
-// { id, name, description, exercises[], createdAt, updatedAt }
+// { id, name, description, exercises[], criteria, createdAt, updatedAt }
+// criteria (optionnel) : { subMuscles, equipment, level, durationMin } — critères
+// de génération d'origine (WorkoutBuilderScreen), utilisés pour ré-ouvrir
+// l'écran de création pré-rempli lors d'une modification. Absent pour les
+// séances manuelles (isManual) ou créées avant cette fonctionnalité.
const STORAGE_KEY = 'athly:savedWorkouts:v1';
@@ -59,6 +63,7 @@ export async function saveWorkout(input) {
description: String(input.description || '').trim(),
exercises: sanitizeExercises(input.exercises),
isManual: !!input.isManual,
+ criteria: input.criteria || null,
createdAt: now,
updatedAt: now,
};
diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js
new file mode 100644
index 0000000..3c7c610
--- /dev/null
+++ b/front/src/services/social.service.js
@@ -0,0 +1,119 @@
+import API from '../api/api';
+
+// ─── Amis (Brique III) ────────────────────────────────────────────────────────
+
+export async function searchUsers(query) {
+ const res = await API.get(`/friends/search?q=${encodeURIComponent(query)}`);
+ return res.data;
+}
+
+export async function sendFriendRequest(friendId) {
+ const res = await API.post('/friends/request', { friendId });
+ return res.data;
+}
+
+export async function acceptFriendRequest(requestId) {
+ const res = await API.put(`/friends/accept/${requestId}`);
+ return res.data;
+}
+
+export async function declineFriendRequest(requestId) {
+ const res = await API.put(`/friends/decline/${requestId}`);
+ return res.data;
+}
+
+// Annule une demande d'ami que J'AI ENVOYÉE (pendant du "refuser" côté destinataire).
+export async function cancelFriendRequest(requestId) {
+ const res = await API.delete(`/friends/request/${requestId}`);
+ return res.data;
+}
+
+// Retire un ami (rompt une amitié acceptée) — l'un ou l'autre membre peut le faire.
+export async function removeFriend(friendshipId) {
+ const res = await API.delete(`/friends/${friendshipId}`);
+ return res.data;
+}
+
+export async function getFriendsList() {
+ const res = await API.get('/friends/list');
+ return res.data;
+}
+
+export async function getPendingRequests() {
+ const res = await API.get('/friends/pending');
+ return res.data;
+}
+
+export async function getFriendProfile(friendId) {
+ const res = await API.get(`/friends/profile/${friendId}`);
+ return res.data;
+}
+
+export async function getLeaderboard() {
+ const res = await API.get('/friends/leaderboard');
+ return res.data;
+}
+
+// Classement par exercice au sein du réseau d'amis (meilleur poids soulevé).
+export async function getExerciseLeaderboard(exercise) {
+ const res = await API.get(`/exercises/leaderboard?exercise=${encodeURIComponent(exercise)}`);
+ return res.data;
+}
+
+// Tous mes records (un par exercice déjà pratiqué) — alimente le sélecteur
+// "mettre en avant jusqu'à 6 records" du profil.
+export async function getMyRecords() {
+ const res = await API.get('/exercises/my-records');
+ return res.data;
+}
+
+// ─── Groupes de Streak (Brique IV) ────────────────────────────────────────────
+
+export async function getMyGroup() {
+ const res = await API.get('/groups/my-group');
+ return res.data;
+}
+
+export async function inviteToGroup(friendIds, name) {
+ const res = await API.post('/groups/invite', { friendIds, name });
+ return res.data;
+}
+
+export async function respondToGroupInvite(groupId, accept) {
+ const res = await API.put(`/groups/respond/${groupId}`, { accept });
+ return res.data;
+}
+
+export async function shakeMember(groupId, memberId) {
+ const res = await API.post(`/groups/${groupId}/shake/${memberId}`);
+ return res.data;
+}
+
+export async function checkGroupStreak(groupId) {
+ const res = await API.post(`/groups/${groupId}/check-streak`);
+ return res.data;
+}
+
+export async function leaveGroup() {
+ const res = await API.post('/groups/leave');
+ return res.data;
+}
+
+// ─── Flux d'activité « Taquineries & High-Fives » (Brique IV) ─────────────────
+
+export async function getActivityFeed() {
+ const res = await API.get('/activity/feed');
+ return res.data;
+}
+
+export async function reactToActivityEvent(eventId, emoji) {
+ const res = await API.post(`/activity/${eventId}/react`, { emoji });
+ return res.data;
+}
+
+// ─── Parrainage (Brique III) ──────────────────────────────────────────────────
+
+export async function claimReferral(code) {
+ const res = await API.post('/referral/claim', { code });
+ return res.data;
+}
diff --git a/front/src/services/stats.service.js b/front/src/services/stats.service.js
index dd28fce..dcd175e 100644
--- a/front/src/services/stats.service.js
+++ b/front/src/services/stats.service.js
@@ -315,6 +315,32 @@ export async function addRitualLog(ritualId, ritualLabel, durationSeconds = 300,
return log;
}
+// Enregistre un gain d'XP dont la source n'est pas une séance (objet
+// d'inventaire consommé, bonus de streak de groupe...). Sert uniquement à
+// synchroniser l'XP/niveau affiché sur le Profil (calculé localement à partir
+// des logs) avec les gains accordés côté backend. Exclu du streak et de
+// l'historique de séances (voir WorkoutLogsContext : sessionLogs/activityLogs).
+export async function addBonusXpLog(source, xpEarned) {
+ if (!xpEarned || xpEarned <= 0) return null;
+ const all = await readAll();
+ const log = {
+ id: genId(),
+ date: new Date().toISOString(),
+ name: source,
+ type: 'item_bonus',
+ exercises: [],
+ totalVolume: 0,
+ setsCompleted: 0,
+ totalSets: 0,
+ muscleDistribution: {},
+ durationSeconds: 0,
+ xpEarned: Math.round(xpEarned),
+ notes: '',
+ };
+ await writeAll([log, ...all]);
+ return log;
+}
+
export async function removeLog(id) {
const all = await readAll();
await writeAll(all.filter((x) => x.id !== id));
diff --git a/front/src/services/title.service.js b/front/src/services/title.service.js
new file mode 100644
index 0000000..177bac3
--- /dev/null
+++ b/front/src/services/title.service.js
@@ -0,0 +1,14 @@
+import API from '../api/api';
+
+// ─── Titres déblocables (Section X) ────────────────────────────────────────────
+
+export async function getTitles() {
+ const res = await API.get('/profile/titles');
+ return res.data;
+}
+
+// titleId: string pour équiper, null pour déséquiper.
+export async function equipTitle(titleId) {
+ const res = await API.post('/profile/equip-title', { titleId });
+ return res.data;
+}
diff --git a/front/src/services/weight.service.js b/front/src/services/weight.service.js
new file mode 100644
index 0000000..5fbf568
--- /dev/null
+++ b/front/src/services/weight.service.js
@@ -0,0 +1,15 @@
+import API from '../api/api';
+
+// ─── Suivi de poids (Section VI) ──────────────────────────────────────────────
+
+export async function getWeightHistory() {
+ const res = await API.get('/weight/history');
+ return res.data;
+}
+
+// `date` optionnelle (ISO string) — permet une saisie rétroactive depuis la
+// modale de rappel hebdomadaire sans forcer "aujourd'hui".
+export async function logWeight(weight, date) {
+ const res = await API.post('/weight', date ? { weight, date } : { weight });
+ return res.data;
+}
diff --git a/front/src/services/workout.service.js b/front/src/services/workout.service.js
deleted file mode 100644
index a56a6a5..0000000
--- a/front/src/services/workout.service.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import API from '../api/api';
-
-export async function getWorkouts() {
- try {
- const response = await API.get('/workouts');
- return response.data.workouts;
- } catch {
- return [];
- }
-}
-
-export async function getWorkout(id) {
- try {
- const response = await API.get(`/workouts/${id}`);
- return response.data.workout;
- } catch {
- return null;
- }
-}
-
-export async function createWorkout(workout) {
- try {
- const response = await API.post('/workouts', workout);
- return response.data.workout;
- } catch {
- return null;
- }
-}
diff --git a/front/src/services/workouts.service.js b/front/src/services/workouts.service.js
new file mode 100644
index 0000000..adb00b9
--- /dev/null
+++ b/front/src/services/workouts.service.js
@@ -0,0 +1,36 @@
+import API from '../api/api';
+
+// ─── Cycle de vie des séances côté backend ────────────────────────────────────
+// Draft (auto-save pendant la séance) → finalize (calcul XP/records serveur)
+// → complete (marquage léger fire-and-forget). Consommé par useWorkoutState
+// (draft/finalize) et WorkoutScreen (complete) — les écrans et hooks ne
+// touchent jamais l'API brute directement.
+
+// Crée le brouillon serveur de la séance en cours (première sauvegarde).
+export async function createWorkoutDraft({ name, exercises, notes }) {
+ const res = await API.post('/workouts/draft', {
+ name, exercises, notes, status: 'draft',
+ });
+ const data = res && res.data ? res.data : res;
+ return data && data.workout ? data.workout : null;
+}
+
+// Met à jour le brouillon serveur existant (auto-save débouncé).
+export async function updateWorkoutDraft(id, { exercises, notes, durationSeconds }) {
+ const res = await API.patch(`/workouts/${id}/draft`, {
+ exercises, notes, durationSeconds,
+ });
+ const data = res && res.data ? res.data : res;
+ return data && data.workout ? data.workout : data;
+}
+
+// Finalise la séance : le backend recalcule XP, records et stats (anti-cheat).
+export async function finalizeWorkout(id, payload) {
+ const res = await API.post(`/workouts/${id}/finalize`, payload);
+ return res && res.data ? res.data : res;
+}
+
+// Marque la séance comme terminée — best-effort, jamais bloquant.
+export function completeWorkout(id) {
+ return API.post(`/workouts/${id}/complete`);
+}
diff --git a/front/src/services/xpSync.service.js b/front/src/services/xpSync.service.js
new file mode 100644
index 0000000..3abf1b2
--- /dev/null
+++ b/front/src/services/xpSync.service.js
@@ -0,0 +1,69 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import API from '../api/api';
+
+// ─── Synchronisation XP locale → backend (Section X) ───────────────────────────
+// L'app reste local-first pour l'XP (AsyncStorage, réactif, jamais bloqué par
+// le réseau) — mais le backend doit rester la Source de Vérité pour tout ce
+// qui est gated côté serveur (coffres niveau 11+, conditions de titres comme
+// "Le Titan"). Ce service pousse silencieusement le total XP local vers
+// POST /api/users/me/sync-xp à chaque événement clé (voir WorkoutLogsContext.js
+// et App.js) ; le backend applique un ratchet (n'accepte jamais une baisse),
+// donc rejouer ce sync n'importe quand est toujours sans danger.
+//
+// Pas de détection réseau active (pas de NetInfo dans ce projet) : en cas
+// d'échec, isXpSynced passe à false et CHAQUE prochain événement clé (dont le
+// prochain démarrage de l'app) retente — cohérent avec le reste de l'app, qui
+// n'observe jamais la connectivité activement (voir api.js, mode dégradé
+// "tente et dégrade" plutôt que "surveille puis agit").
+
+const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1';
+
+// Garde en mémoire (pas AsyncStorage) la dernière valeur réellement envoyée
+// avec succès cette session — évite de spammer l'API à chaque log ajouté
+// pendant une même séance si l'xp n'a pas bougé entre deux appels.
+let lastSyncedXp = null;
+
+/**
+ * Pousse `totalXP` (cumul local total, jamais un delta) vers le backend.
+ * Best-effort : ne lève jamais, best pour être appelée en fire-and-forget
+ * depuis n'importe quel point d'action sans `await` bloquant l'UI.
+ *
+ * @param {number} totalXP
+ * @returns {Promise<{level:number, xp:number, rank:string, newlyUnlockedTitles:string[]}|null>}
+ */
+export async function syncXp(totalXP) {
+ if (!Number.isFinite(totalXP) || totalXP < 0) return null;
+ const rounded = Math.round(totalXP);
+ if (rounded === lastSyncedXp) return null; // rien de nouveau depuis le dernier sync réussi
+
+ try {
+ const res = await API.post('/users/me/sync-xp', { xp: rounded });
+ lastSyncedXp = rounded;
+ AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'true').catch(() => {});
+ return res.data ?? null;
+ } catch (_) {
+ AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'false').catch(() => {});
+ return null;
+ }
+}
+
+/**
+ * À appeler au démarrage de l'app (check-up de cohérence) : relit le flag
+ * laissé par un échec précédent et retente si besoin. Comme `syncXp` est de
+ * toute façon appelée à chaque événement clé, ceci n'est qu'un filet de
+ * sécurité supplémentaire pour le cas "aucune action XP depuis le retour du
+ * réseau" (ex: l'utilisateur rouvre juste l'app sans s'entraîner).
+ *
+ * @param {number} currentTotalXP — total XP local ACTUEL (pas la valeur figée
+ * au moment de l'échec — le ratchet backend prend de toute façon le max).
+ */
+export async function retryPendingXpSync(currentTotalXP) {
+ try {
+ const flag = await AsyncStorage.getItem(XP_SYNCED_FLAG_KEY);
+ if (flag === 'false') {
+ await syncXp(currentTotalXP);
+ }
+ } catch (_) {
+ // best-effort
+ }
+}
diff --git a/front/src/styles/global.js b/front/src/styles/global.js
deleted file mode 100644
index e4fcf5e..0000000
--- a/front/src/styles/global.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import { StyleSheet } from 'react-native';
-import { Colors } from '../constants/theme';
-
-export const globalStyles = StyleSheet.create({
- // ── Conteneurs ────────────────────────────────────────────────────────────
- safeDeep: {
- flex: 1,
- backgroundColor: Colors.backgroundDeep,
- },
- safeAbyss: {
- flex: 1,
- backgroundColor: Colors.bgAbyss,
- },
- container: {
- flex: 1,
- backgroundColor: Colors.background,
- padding: 16,
- },
- scrollContent: {
- paddingHorizontal: 20,
- paddingBottom: 40,
- },
-
- // ── Mise en page ──────────────────────────────────────────────────────────
- centered: {
- justifyContent: 'center',
- alignItems: 'center',
- },
- rowBetween: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- flex1: { flex: 1 },
-
- // ── Cards ─────────────────────────────────────────────────────────────────
- card: {
- backgroundColor: Colors.cardDeep,
- borderRadius: 16,
- padding: 16,
- },
- cardLegacy: {
- backgroundColor: Colors.card,
- borderRadius: 16,
- paddingVertical: 14,
- paddingHorizontal: 14,
- },
- glassCard: {
- backgroundColor: Colors.glassBg,
- borderWidth: 1,
- borderColor: Colors.glassBorder,
- borderRadius: 18,
- overflow: 'hidden',
- },
-
- // ── Typographie ───────────────────────────────────────────────────────────
- title: {
- color: Colors.textPrimary,
- fontSize: 22,
- fontWeight: 'bold',
- },
- subtitle: {
- color: Colors.textSecondary,
- fontSize: 14,
- marginTop: 4,
- },
- sectionLabel: {
- color: Colors.textPrimary,
- fontSize: 12,
- fontWeight: '800',
- letterSpacing: 1.2,
- textTransform: 'uppercase',
- marginBottom: 10,
- },
- sectionLinkText: {
- color: Colors.primary,
- fontSize: 12,
- fontWeight: '700',
- },
-
- // ── Boutons ───────────────────────────────────────────────────────────────
- primaryBtn: {
- backgroundColor: Colors.primary,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 15,
- shadowColor: Colors.primary,
- shadowOffset: { width: 0, height: 6 },
- shadowOpacity: 0.4,
- shadowRadius: 14,
- elevation: 8,
- },
- primaryBtnText: {
- color: '#fff',
- fontSize: 14,
- fontWeight: '800',
- },
- ghostBtn: {
- borderWidth: 1.5,
- borderColor: Colors.primary,
- borderRadius: 14,
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 13,
- backgroundColor: 'transparent',
- },
- ghostBtnText: {
- color: Colors.primary,
- fontWeight: '700',
- fontSize: 14,
- },
-
- // ── Écrans vides ──────────────────────────────────────────────────────────
- emptyState: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- paddingHorizontal: 32,
- gap: 14,
- },
- emptyText: {
- color: Colors.textMuted,
- fontSize: 14,
- textAlign: 'center',
- },
-});
diff --git a/front/vercel.json b/front/vercel.json
new file mode 100644
index 0000000..e4637d2
--- /dev/null
+++ b/front/vercel.json
@@ -0,0 +1,7 @@
+{
+ "buildCommand": "npm run build:web",
+ "outputDirectory": "dist",
+ "rewrites": [
+ { "source": "/(.*)", "destination": "/index.html" }
+ ]
+}