fix(state): refresh history + game progress + profile on screen focus

useHistory and useGameProgress each instantiate a private state per
consumer (no global store), so an addScan from Scanner did NOT appear
in Home's RecentScans nor bump Profile stats until the app was killed
and relaunched.

- RecentScans now reload()s its history from AsyncStorage on
  useFocusEffect — covers the post-scan return to Home.
- ProfileScreen reload()s both useGameProgress and useUserProfile on
  useFocusEffect so the Bento stats and avatar are fresh after edits.
- useGameProgress wraps loadProgress in useCallback and exposes it,
  enabling external triggers (used by the two screens above).
- ProfileScreen also fixes a brittle stat lookup: the BENTO_STATS keys
  ("scans"/"grapes"/"streak"/"xp") didn't match the GameProgress shape
  ("totalScans"/"uniqueGrapes.length"/"bestStreak"/"xp"). Now an
  explicit statValues map drives the render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 14:03:28 +02:00
parent a3cd906a6d
commit 59d4ae8ee4
3 changed files with 43 additions and 19 deletions

View file

@ -1,5 +1,6 @@
import { useCallback } from "react";
import { View, StyleSheet, Platform } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
@ -17,7 +18,17 @@ const MAX_RECENT = 3;
export default function RecentScans() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { history, isLoading, toggleFavorite, deleteScan } = useHistory();
const { history, isLoading, toggleFavorite, deleteScan, reload } =
useHistory();
// useHistory() crée une instance state locale par consumer → l'addScan fait
// depuis Scanner ne remonte pas ici. On reload depuis AsyncStorage à chaque
// focus du tab Home pour récupérer les scans ajoutés ailleurs.
useFocusEffect(
useCallback(() => {
reload();
}, [reload]),
);
// Loading + pas encore de cache → skeleton
if (isLoading && history.length === 0) {

View file

@ -40,16 +40,16 @@ export function useGameProgress() {
const [newlyUnlockedBadges, setNewlyUnlockedBadges] = useState<BadgeId[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadProgress();
}, []);
async function loadProgress() {
const loadProgress = useCallback(async () => {
setIsLoading(true);
const saved = await storage.get<GameProgress>(storage.KEYS.GAME_PROGRESS);
setProgress(saved ?? INITIAL_PROGRESS);
setIsLoading(false);
}
}, []);
useEffect(() => {
loadProgress();
}, [loadProgress]);
const processDetection = useCallback(async (detection: Detection): Promise<number> => {
let xpEarned = 0;

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import {
View,
ScrollView,
@ -8,7 +8,7 @@ import {
TouchableOpacity,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
@ -27,19 +27,34 @@ const { width } = Dimensions.get("window");
const STAT_CARD_SIZE = (width - 56) / 2;
const BENTO_STATS = [
{ key: "scans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
{ key: "grapes", icon: "leaf-outline", iconColor: "#10B981", label: "profile.uniqueGrapes" },
{ key: "streak", icon: "flame-outline", iconColor: "#EF4444", label: "profile.bestStreak" },
{ key: "totalScans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
{ key: "uniqueGrapes", icon: "leaf-outline", iconColor: "#10B981", label: "profile.uniqueGrapes" },
{ key: "bestStreak", icon: "flame-outline", iconColor: "#EF4444", label: "profile.bestStreak" },
{ key: "xp", icon: "star-outline", iconColor: "#6366F1", label: "profile.xpTotal" },
];
] as const;
export default function ProfileScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { progress } = useGameProgress();
const { profile, updateProfile } = useUserProfile();
const { progress, reload: reloadProgress } = useGameProgress();
const { profile, updateProfile, reload: reloadProfile } = useUserProfile();
const [editing, setEditing] = useState(false);
// Refresh stats + profil quand le screen reprend le focus (post-scan, post-edit, etc.)
useFocusEffect(
useCallback(() => {
reloadProgress();
reloadProfile();
}, [reloadProgress, reloadProfile]),
);
const statValues: Record<string, number> = {
totalScans: progress.totalScans ?? 0,
uniqueGrapes: progress.uniqueGrapes?.length ?? 0,
bestStreak: progress.bestStreak ?? 0,
xp: progress.xp ?? 0,
};
function handleBack() {
if (navigation.canGoBack()) {
navigation.goBack();
@ -109,9 +124,7 @@ export default function ProfileScreen() {
<Ionicons name={stat.icon as keyof typeof Ionicons.glyphMap} size={22} color={stat.iconColor} />
</View>
<Text style={styles.statValue}>
{stat.key === "grapes"
? progress.uniqueGrapes?.length ?? 0
: (progress[stat.key as keyof typeof progress] as number) ?? 0}
{statValues[stat.key] ?? 0}
</Text>
<Text style={styles.statLabel}>{t(stat.label)}</Text>
</View>