diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js
index 84a9f38..8a60f65 100644
--- a/back/controllers/reward.controller.js
+++ b/back/controllers/reward.controller.js
@@ -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)
@@ -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();
diff --git a/back/controllers/workoutLobby.controller.js b/back/controllers/workoutLobby.controller.js
index 5b35836..215f455 100644
--- a/back/controllers/workoutLobby.controller.js
+++ b/back/controllers/workoutLobby.controller.js
@@ -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 {
@@ -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())),
);
diff --git a/back/models/User.js b/back/models/User.js
index dba79ee..33d5f17 100644
--- a/back/models/User.js
+++ b/back/models/User.js
@@ -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) :
diff --git a/back/tests/workoutLobby.test.js b/back/tests/workoutLobby.test.js
index 9652abc..7f8acc5 100644
--- a/back/tests/workoutLobby.test.js
+++ b/back/tests/workoutLobby.test.js
@@ -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;
diff --git a/front/.env.example b/front/.env.example
index 014189b..65d82c1 100644
--- a/front/.env.example
+++ b/front/.env.example
@@ -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=
diff --git a/front/app.json b/front/app.json
index 7485a26..bf1268e 100644
--- a/front/app.json
+++ b/front/app.json
@@ -3,6 +3,7 @@
"name": "Athly",
"slug": "athly-app",
"version": "1.0.0",
+ "scheme": "athly",
"orientation": "portrait",
"icon": "./assets/favicon.png",
"userInterfaceStyle": "light",
diff --git a/front/package-lock.json b/front/package-lock.json
index 71a2e72..c0a28ff 100644
--- a/front/package-lock.json
+++ b/front/package-lock.json
@@ -17,11 +17,14 @@
"@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",
@@ -5787,6 +5790,24 @@
"react-native": "*"
}
},
+ "node_modules/expo-auth-session": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz",
+ "integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-application": "~7.0.8",
+ "expo-constants": "~18.0.13",
+ "expo-crypto": "~15.0.9",
+ "expo-linking": "~8.0.12",
+ "expo-web-browser": "~15.0.11",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -5801,6 +5822,18 @@
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "15.0.9",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
+ "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-file-system": {
"version": "19.0.23",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
@@ -5855,6 +5888,20 @@
"react-native": "*"
}
},
+ "node_modules/expo-linking": {
+ "version": "8.0.12",
+ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
+ "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-constants": "~18.0.13",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.26",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
@@ -5935,6 +5982,16 @@
"react-native": "*"
}
},
+ "node_modules/expo-web-browser": {
+ "version": "15.0.11",
+ "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
+ "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
diff --git a/front/package.json b/front/package.json
index d63c2a8..69cf2ef 100644
--- a/front/package.json
+++ b/front/package.json
@@ -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": {
diff --git a/front/src/data/profileThemes.js b/front/src/data/profileThemes.js
index 4b1a492..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',
@@ -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',
diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js
new file mode 100644
index 0000000..54794e4
--- /dev/null
+++ b/front/src/hooks/useGoogleAuth.js
@@ -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,
+ };
+}
diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js
index 4aecc0d..0ae90bd 100644
--- a/front/src/screens/Auth/LoginScreen.js
+++ b/front/src/screens/Auth/LoginScreen.js
@@ -10,8 +10,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 { login } from '../../services/auth.service';
+import { login, googleLogin } from '../../services/auth.service';
import { useAuth } from '../../context/AuthContext';
+import { useGoogleAuth } from '../../hooks/useGoogleAuth';
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, 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/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js
index 3a2c468..b5a1c9e 100644
--- a/front/src/screens/Profile/SettingsScreen.js
+++ b/front/src/screens/Profile/SettingsScreen.js
@@ -925,7 +925,7 @@ export default function SettingsScreen({ navigation }) {
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éances testable (Prêt / Actif / Validé - le 4e, En sommeil,
s'obtient en ne touchant à aucun des trois). Rejouable sans doublons.
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/notificationService.js b/front/src/services/notificationService.js
index b7055ce..df28eb6 100644
--- a/front/src/services/notificationService.js
+++ b/front/src/services/notificationService.js
@@ -3,7 +3,12 @@ import { Platform } from 'react-native';
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
-const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2';
+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';
@@ -161,9 +166,14 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY
// une nouvelle, pour éviter l'accumulation de rappels dupliqués en arrière-plan.
await cancelDailyReminder();
+ // 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 = null;
+ let previous = lastTitle ? { msg: { title: lastTitle } } : null;
for (let dayOffset = 0; dayOffset < count; dayOffset++) {
const date = new Date(now);
@@ -195,6 +205,9 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY
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;