Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions back/controllers/reward.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ const ACHIEVEMENT_CATALOG = {
category: 'social',
hidden: false,
},
MULTI_SESSIONS_5: {
id: 'MULTI_SESSIONS_5',
name: 'Entraînement en Duo',
description: "Vous avez terminé 5 séances en mode Multi.",
category: 'social',
hidden: false,
},
MULTI_SESSIONS_30: {
id: 'MULTI_SESSIONS_30',
name: "Frères d'Armes",
description: "Vous avez terminé 30 séances en mode Multi.",
category: 'social',
hidden: false,
},
};

// Nombre total de trophées dans le catalogue (utile pour les stats)
Expand Down Expand Up @@ -256,6 +270,18 @@ async function checkAndUnlockAchievements(userId) {
}
}

// Trophées gradués sur le cumul de séances Multi terminées
// (totalMultiSessions, incrémenté dans workoutLobby.controller.js → finishLobby).
const MULTI_SESSION_ACHIEVEMENTS = [
[5, 'MULTI_SESSIONS_5'],
[30, 'MULTI_SESSIONS_30'],
];
for (const [threshold, achievementId] of MULTI_SESSION_ACHIEVEMENTS) {
if (!unlockedIds.has(achievementId) && user.totalMultiSessions >= threshold) {
tryUnlock(achievementId);
}
}

if (newlyUnlocked.length > 0) {
user.markModified('achievements');
await user.save();
Expand Down
13 changes: 11 additions & 2 deletions back/controllers/workoutLobby.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,9 @@ exports.unreadyLobby = async (req, res, next) => {
/**
* Passe le statut du membre appelant à 'finished'. Dès que TOUS les membres
* ont fini, le lobby passe 'completed', le bonus XP Multi est figé
* (`xpBonusPercent`), et les trophées sociaux Multi sont vérifiés pour
* chaque membre (FIRST_MULTI_SESSION, MULTI_SQUAD_FULL à 5 joueurs).
* (`xpBonusPercent`), le compteur totalMultiSessions de chaque membre est
* incrémenté, et les trophées sociaux Multi sont vérifiés (FIRST_MULTI_SESSION,
* MULTI_SQUAD_FULL à 5 joueurs, MULTI_SESSIONS_5, MULTI_SESSIONS_30).
*/
exports.finishLobby = async (req, res, next) => {
try {
Expand Down Expand Up @@ -359,6 +360,14 @@ exports.finishLobby = async (req, res, next) => {
if (allFinished) {
// Best-effort : les trophées ne doivent jamais faire échouer la clôture.
try {
// Incrémente le compteur AVANT de vérifier les trophées gradués
// (MULTI_SESSIONS_5 / MULTI_SESSIONS_30), sinon le seuil serait
// évalué sur l'ancienne valeur.
await User.updateMany(
{ _id: { $in: lobby.members.map((m) => m.user) } },
{ $inc: { totalMultiSessions: 1 } },
);

const results = await Promise.all(
lobby.members.map((m) => checkAndUnlockAchievements(m.user.toString())),
);
Expand Down
5 changes: 5 additions & 0 deletions back/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ const UserSchema = new mongoose.Schema(
// cosmétique Rouge Sang Unique à 100 coffres).
totalChestsOpened: { type: Number, default: 0, min: 0 },

// Cumul du nombre de séances Multi terminées (lobby passé 'completed',
// voir workoutLobby.controller.js → finishLobby) — condition des trophées
// gradués MULTI_SESSIONS_5 / MULTI_SESSIONS_30 (reward.controller.js).
totalMultiSessions: { type: Number, default: 0, min: 0 },

// Cosmétiques Uniques définitivement débloqués (réclamés depuis
// l'inventaire — voir inventory.controller.js → claimUniqueItem).
// Clés libres du catalogue front (BorderPicker.js / profileThemes.js) :
Expand Down
40 changes: 40 additions & 0 deletions back/tests/workoutLobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,46 @@ describe('Lobby Multi — Section VII', () => {
expect(res.body.newlyUnlocked).not.toContain('MULTI_SQUAD_FULL');
});

it('✅ Incrémente totalMultiSessions pour chaque membre à la clôture', async () => {
const lobbyId = await createActiveLobby([alice, bob]);
await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`);
await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`);

const aliceUser = await User.findById(alice.userId);
const bobUser = await User.findById(bob.userId);
expect(aliceUser.totalMultiSessions).toBe(1);
expect(bobUser.totalMultiSessions).toBe(1);
});

it('✅ Débloque MULTI_SESSIONS_5 à la 5e séance Multi terminée', async () => {
await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 4 } });

const lobbyId = await createActiveLobby([alice, bob]);
await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`);
const res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`);

const aliceUser = await User.findById(alice.userId);
const bobUser = await User.findById(bob.userId);
expect(aliceUser.totalMultiSessions).toBe(5);
expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(true);
// Bob n'a fait qu'1 séance Multi : pas encore débloqué pour lui
expect(bobUser.totalMultiSessions).toBe(1);
expect(bobUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(false);
expect(res.body.newlyUnlocked).not.toContain('MULTI_SESSIONS_5');
});

it('✅ Débloque MULTI_SESSIONS_30 à la 30e séance Multi terminée', async () => {
await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 29 } });

const lobbyId = await createActiveLobby([alice, bob]);
await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`);
await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`);

const aliceUser = await User.findById(alice.userId);
expect(aliceUser.totalMultiSessions).toBe(30);
expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_30')).toBe(true);
});

it("❌ 422 si le lobby n'est pas encore actif (waiting)", async () => {
const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`);
const lobbyId = res.body.lobby._id;
Expand Down
19 changes: 19 additions & 0 deletions front/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,22 @@ TOKEN_KEY=athly_token
# ================================
# development | production
APP_ENV=development


# ================================
# GOOGLE OAUTH (Section VIII)
# ================================
# Client IDs OAuth 2.0 créés dans Google Cloud Console (console.cloud.google.com
# → APIs & Services → Identifiants). Un ID par plateforme car chacun a un
# type d'application différent :
# - GOOGLE_EXPO_CLIENT_ID : type "Web", utilisé pour le proxy Expo Go en dev
# - GOOGLE_IOS_CLIENT_ID : type "iOS", bundle ID com.clementin.athly
# - GOOGLE_ANDROID_CLIENT_ID: type "Android", package com.clementin.athly
# - GOOGLE_WEB_CLIENT_ID : type "Web", utilisé pour la version PWA/web
# Tant qu'ils ne sont pas renseignés, le bouton Google reste désactivé côté
# front (voir useGoogleAuth.js) — le backend répond 501 de toute façon sans
# GOOGLE_CLIENT_ID configuré côté serveur (voir back/.env).
GOOGLE_EXPO_CLIENT_ID=
GOOGLE_IOS_CLIENT_ID=
GOOGLE_ANDROID_CLIENT_ID=
GOOGLE_WEB_CLIENT_ID=
1 change: 1 addition & 0 deletions front/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "Athly",
"slug": "athly-app",
"version": "1.0.0",
"scheme": "athly",
"orientation": "portrait",
"icon": "./assets/favicon.png",
"userInterfaceStyle": "light",
Expand Down
57 changes: 57 additions & 0 deletions front/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,26 @@
"@react-navigation/stack": "^7.8.7",
"axios": "^1.16.0",
"expo": "~54.0.27",
"expo-auth-session": "~7.0.11",
"expo-crypto": "~15.0.9",
"expo-haptics": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-notifications": "^0.32.17",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"lottie-react-native": "~7.3.1",
"qs": "^6.15.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-web": "~0.19.13",
"react-native-chart-kit": "^6.12.2",
"react-native-dotenv": "^3.4.11",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1"
"react-native-svg": "15.12.1",
"react-native-web": "~0.19.13"
},
"private": true,
"devDependencies": {
Expand Down
6 changes: 4 additions & 2 deletions front/src/data/profileThemes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -135,8 +137,8 @@ export const PROFILE_THEMES = [
unlockLevel: 0,
requiresCosmetic: 'THEME_BLOODSANG',
requiresChests: 100, // pour le texte de progression avant réclamation
accentColor: '#FF2E4D',
glowColor: 'rgba(163,0,0,0.75)',
accentColor: Colors.uniqueBloodBright,
glowColor: Colors.uniqueBloodGlow,
shimmer: true,
hasGlow: true,
bgVariant: 'blood',
Expand Down
49 changes: 49 additions & 0 deletions front/src/hooks/useGoogleAuth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
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();

// ─── 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.

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 idToken = useMemo(() => {
if (!response || response.type !== 'success') return null;
return response.authentication?.idToken || response.params?.id_token || null;
}, [response]);

return {
isConfigured,
request,
response,
idToken,
promptAsync,
};
}
Loading
Loading