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
9 changes: 5 additions & 4 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ActivityIndicator, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { View } from '@/components/Themed';
import { useProgress } from '@/hooks/useProgress';
import { useProgress } from '@/context/ProgressContext';
import Home from '@/screens/Home';
import ChapterMap from '@/screens/ChapterMap';
import Quiz from '@/screens/Quiz';
Expand All @@ -15,9 +15,10 @@ type ViewState =
| { name: 'quiz'; levelId: string; questions: Question[] }
| { name: 'mixed'; questions: Question[] };

// Phase 3:訪客模式離線答題流程。跟 apps/web 的 App.tsx 一樣用一個 view 狀態機切畫面,
// 差別是這裡範圍只到 Home tab(levellist/quiz/mixed 都是從這個 tab 進去的子畫面),
// notes/stats tab 各自獨立,不需要共用同一個狀態機。
// 跟 apps/web 的 App.tsx 一樣用一個 view 狀態機切畫面,差別是這裡範圍只到 Home tab
// (levellist/quiz/mixed 都是從這個 tab 進去的子畫面),notes/stats tab 各自獨立,不需要共用
// 同一個狀態機。進度資料(訪客 AsyncStorage/登入後打 API)統一由 ProgressProvider 提供,
// 跟 Profile tab 共用同一份 state。
export default function HomeScreen() {
const { progress, hydrated, answerQuestion, toggleSaved, finishLevel, finishReview } = useProgress();
const [view, setView] = useState<ViewState>({ name: 'home' });
Expand Down
49 changes: 11 additions & 38 deletions apps/mobile/app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,23 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAuth, useSSO, useUser } from '@clerk/expo';
import type { Progress } from '@easylearn/core';

import { Text, View } from '@/components/Themed';
import { request } from '@/lib/api';
import { useProgress } from '@/context/ProgressContext';

// Phase 2:唯讀 Profile——只讀 GET /api/progress 顯示 XP/streak/關卡數,不寫入。
// 這個畫面也是驗證「mobile 帶 Bearer token 打 web API 不用改 middleware」這個關鍵假設的地方
// Phase 4:不再自己打 GET /api/progress,改讀 ProgressProvider 共用的 state——跟 Home tab
// 是同一份進度,登入時的搬遷(migrate-local)也只會在 provider 裡觸發一次
export default function ProfileScreen() {
const { isLoaded, isSignedIn, getToken, signOut } = useAuth();
const { isLoaded, isSignedIn, signOut } = useAuth();
const { user } = useUser();
const { startSSOFlow } = useSSO();
const [progress, setProgress] = useState<Progress | null>(null);
const [loading, setLoading] = useState(false);
const { progress, hydrated } = useProgress();
const [error, setError] = useState<string | null>(null);
const [signingIn, setSigningIn] = useState(false);
const insets = useSafeAreaInsets();
const containerStyle = [styles.container, { paddingTop: insets.top }];

const loadProgress = useCallback(async () => {
setLoading(true);
setError(null);
try {
const token = await getToken();
const data = await request<{ progress: Progress }>('/api/progress', { token });
setProgress(data.progress);
} catch (err) {
setError(err instanceof Error ? err.message : '讀取進度失敗');
} finally {
setLoading(false);
}
}, [getToken]);

// 只在 isSignedIn 變成 true 時打一次,不能依賴 loadProgress 本身:
// Clerk 的 getToken 每次 render 不保證是同一個 reference,若依賴 loadProgress
// 會導致這個 effect 每次 render 都重跑,變成無限狂打 GET /api/progress
useEffect(() => {
if (isSignedIn) loadProgress();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);

const handleSignIn = async () => {
setError(null);
setSigningIn(true);
Expand Down Expand Up @@ -84,9 +60,7 @@ export default function ProfileScreen() {
return (
<View style={containerStyle}>
<Text style={styles.title}>登入 EasyLearn</Text>
<Text style={styles.subtitle}>
登入後可在多裝置同步學習進度;Home tab 已支援訪客模式離線答題,這裡顯示訪客進度留到 Phase 4(登入同步迴圈)一併處理
</Text>
<Text style={styles.subtitle}>登入後可在多裝置同步學習進度;未登入也能繼續刷題,進度先存在這台裝置。</Text>
<Pressable style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>使用 Google 登入</Text>
</Pressable>
Expand All @@ -95,18 +69,17 @@ export default function ProfileScreen() {
);
}

const totalAnswered = Object.values(progress?.dailyStats ?? {}).reduce((n, d) => n + d.total, 0);
const totalAnswered = Object.values(progress.dailyStats ?? {}).reduce((n, d) => n + d.total, 0);

return (
<View style={styles.container}>
<View style={containerStyle}>
<Text style={styles.title}>
{user?.firstName ?? user?.primaryEmailAddress?.emailAddress ?? '已登入'}
</Text>

{loading && <ActivityIndicator style={styles.spinner} />}
{error && <Text style={styles.error}>{error}</Text>}
{!hydrated && <ActivityIndicator style={styles.spinner} />}

{progress && (
{hydrated && (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<View style={styles.statGrid}>
<StatItem label="總 XP" value={progress.xp} />
<StatItem label="連續學習" value={`${progress.streak.count} 天`} />
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'react-native-reanimated';

import { useColorScheme } from '@/components/useColorScheme';
import { tokenCache } from '@/lib/tokenCache';
import { ProgressProvider } from '@/context/ProgressContext';

// OAuth 登入走系統瀏覽器跳轉回 App 時,讓瀏覽器 session 正常關閉
WebBrowser.maybeCompleteAuthSession();
Expand Down Expand Up @@ -56,7 +57,9 @@ export default function RootLayout() {
return (
<SafeAreaProvider>
<ClerkProvider publishableKey={CLERK_PUBLISHABLE_KEY!} tokenCache={tokenCache}>
<RootLayoutNav />
<ProgressProvider>
<RootLayoutNav />
</ProgressProvider>
</ClerkProvider>
</SafeAreaProvider>
);
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/context/ProgressContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useProgressState } from '@/hooks/useProgress';

type ProgressContextValue = ReturnType<typeof useProgressState>;

const ProgressContext = createContext<ProgressContextValue | null>(null);

// 包住整個 app 一次(掛在 app/_layout.tsx 的 ClerkProvider 內側),讓 Home tab(答題寫入進度)
// 跟 Profile tab(顯示統計)共用同一份 progress state。不要在個別畫面各自呼叫 useProgressState(),
// 那樣會變成兩份互不相通的狀態,登入時的搬遷(migrate-local)邏輯也會被重複觸發。
export const ProgressProvider = ({ children }: { children: ReactNode }) => {
const value = useProgressState();
return <ProgressContext.Provider value={value}>{children}</ProgressContext.Provider>;
};

export const useProgress = () => {
const ctx = useContext(ProgressContext);
if (!ctx) throw new Error('useProgress must be used within a ProgressProvider');
return ctx;
};
203 changes: 155 additions & 48 deletions apps/mobile/hooks/useProgress.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '@clerk/expo';
import {
applyAnswer,
bumpStreak,
bumpXpLog,
todayStr,
type Progress,
type WrongEntryMeta,
} from '@easylearn/core';
import { request } from '@/lib/api';

const STORAGE_KEY = 'easylearn-progress-v1';

Expand All @@ -32,71 +35,175 @@ const migrateWrongIds = (
]),
);

// Phase 3:只做訪客模式(AsyncStorage),不接 Clerk 同步——那是 Phase 4 的範圍。
// AsyncStorage 是非同步 API,跟 web 版 localStorage 同步讀取不同,多一個 hydrated flag
// 讓呼叫端知道「本機資料讀取完成前」不要顯示或覆寫進度。
export const useProgress = () => {
const loadLocal = async (): Promise<Progress> => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return defaultProgress;
const data = JSON.parse(raw);
return { ...defaultProgress, ...data, wrongIds: migrateWrongIds(data.wrongIds) };
} catch {
return defaultProgress;
}
};

// 已登入時打的 API 都回傳伺服器組好的權威 Progress,直接拿來覆蓋樂觀更新的本地 state(跟 apps/web 同一套作法)
const postJson = async (path: string, body: unknown, token: string | null): Promise<Progress> => {
const data = await request<{ progress: Progress }>(path, { method: 'POST', body, token });
return data.progress;
};

// Phase 4:接上 Clerk,跟 apps/web 的 src/hooks/useProgress.ts 同一套雙模式邏輯——
// 未登入沿用 Phase 3 的 AsyncStorage 訪客模式;已登入時打 apps/web 既有的 API,伺服器回傳的
// 權威 Progress 直接覆蓋本地樂觀更新。這個 hook 要被 context/ProgressContext.tsx 的
// ProgressProvider 包成單一實例讓所有 tab 共用,不要在每個畫面各自呼叫,否則會變成互不相通的
// 兩份 state,且登入搬遷(migrate-local)邏輯會被重複觸發。
export const useProgressState = () => {
const { isSignedIn, isLoaded, getToken } = useAuth();
const [progress, setProgress] = useState(defaultProgress);
const [hydrated, setHydrated] = useState(false);
const migratedRef = useRef(false);
// 搬遷時要送目前畫面上看到的這份 progress,不要另外去讀 AsyncStorage:寫入 AsyncStorage 是
// fire-and-forget 的非同步操作(見下面的寫回 effect),登入瞬間最後一次寫入可能還沒落盤,
// 這時如果重新讀 AsyncStorage 可能讀到舊值甚至空值,把還沒作答的空進度誤搬上雲端。
const progressRef = useRef(progress);

useEffect(() => {
progressRef.current = progress;
}, [progress]);

useEffect(() => {
(async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
setProgress({ ...defaultProgress, ...data, wrongIds: migrateWrongIds(data.wrongIds) });
}
} catch {
// 讀取失敗(資料損毀等)就維持 defaultProgress
} finally {
setHydrated(true);
}
setProgress(await loadLocal());
setHydrated(true);
})();
}, []);

// 訪客模式:任何 progress 變動都寫回 AsyncStorage,等 hydrated 才開始寫,避免搶在初始讀取完成前
// 把儲存空間洗成預設值
useEffect(() => {
if (!hydrated) return;
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(progress)).catch(() => {});
}, [progress, hydrated]);
if (!isSignedIn) AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(progress)).catch(() => {});
}, [progress, isSignedIn, hydrated]);

const answerQuestion = useCallback((questionId: string, correct: boolean, chapterId?: string) => {
setProgress((p) => applyAnswer(p, questionId, correct, chapterId));
}, []);
// 登入狀態切換:登入時去讀資料庫(第一次登入且資料庫是空的,就把訪客進度搬過去);登出則換回
// 讀本機 AsyncStorage。刻意等 hydrated 才判斷:AsyncStorage 讀取是非同步的,跟這裡的網路請求
// 誰先完成不保證,沒有這道 guard 可能會先拿到伺服器資料、又被稍後才完成的本地讀取蓋掉。
//
// 這是「終身只搬一次」的設計:isNew 只要曾經翻成 false 就永遠是 false,之後登入一律以伺服器
// 資料為準,不會再把裝置端新增的訪客進度併回去。反覆登出/登入不會觸發第二次搬遷,這是刻意的,
// 不是 bug——見 docs/rn-migration.md Phase 4 的除錯記錄。
useEffect(() => {
if (!isLoaded || !hydrated) return;
if (!isSignedIn) {
migratedRef.current = false;
loadLocal().then(setProgress);
return;
}
if (migratedRef.current) return;
(async () => {
try {
const token = await getToken();
const data = await request<{ progress: Progress; isNew: boolean }>('/api/progress', { token });
if (data.isNew) {
setProgress(await postJson('/api/progress/migrate-local', progressRef.current, token));
} else {
setProgress(data.progress);
}
// 只有成功載入/搬遷完才標記完成;失敗的話留著讓下一次觸發還能重試
migratedRef.current = true;
} catch (err) {
console.error('progress load/migration failed', err);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoaded, isSignedIn, hydrated]);

const toggleSaved = useCallback((questionId: string) => {
setProgress((p) => {
const savedIds = { ...p.savedIds };
if (savedIds[questionId]) delete savedIds[questionId];
else savedIds[questionId] = true;
return { ...p, savedIds };
});
}, []);
const answerQuestion = useCallback(
(questionId: string, correct: boolean, chapterId?: string) => {
setProgress((p) => applyAnswer(p, questionId, correct, chapterId));

if (isSignedIn && migratedRef.current) {
getToken()
.then((token) =>
postJson('/api/progress/answer', { questionId, correct, chapterId, today: todayStr() }, token),
)
.then(setProgress)
.catch((err) => console.error('answerQuestion sync failed', err));
}
},
[isSignedIn, getToken],
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const toggleSaved = useCallback(
(questionId: string) => {
setProgress((p) => {
const savedIds = { ...p.savedIds };
if (savedIds[questionId]) delete savedIds[questionId];
else savedIds[questionId] = true;
return { ...p, savedIds };
});

if (isSignedIn && migratedRef.current) {
getToken()
.then((token) => postJson('/api/progress/save-toggle', { questionId }, token))
.then(setProgress)
.catch((err) => console.error('toggleSaved sync failed', err));
}
},
[isSignedIn, getToken],
);

const finishLevel = useCallback((levelId: string, correct: number, total: number, xpEarned: number) => {
setProgress((p) => {
const prev = p.completedLevels[levelId];
return {
const finishLevel = useCallback(
(levelId: string, correct: number, total: number, xpEarned: number) => {
setProgress((p) => {
const prev = p.completedLevels[levelId];
return {
...p,
xp: p.xp + xpEarned,
streak: bumpStreak(p.streak),
xpLog: bumpXpLog(p.xpLog, xpEarned),
completedLevels: {
...p.completedLevels,
[levelId]: { best: Math.max(prev?.best ?? 0, correct), total },
},
};
});

if (isSignedIn && migratedRef.current) {
getToken()
.then((token) =>
postJson(
'/api/progress/finish-level',
{ levelId, correct, total, xpEarned, today: todayStr() },
token,
),
)
.then(setProgress)
.catch((err) => console.error('finishLevel sync failed', err));
}
},
[isSignedIn, getToken],
);

// 錯題重練結束:只加 XP 和 streak,不動關卡紀錄
const finishReview = useCallback(
(xpEarned: number) => {
setProgress((p) => ({
...p,
xp: p.xp + xpEarned,
streak: bumpStreak(p.streak),
xpLog: bumpXpLog(p.xpLog, xpEarned),
completedLevels: {
...p.completedLevels,
[levelId]: { best: Math.max(prev?.best ?? 0, correct), total },
},
};
});
}, []);
}));

const finishReview = useCallback((xpEarned: number) => {
setProgress((p) => ({
...p,
xp: p.xp + xpEarned,
streak: bumpStreak(p.streak),
xpLog: bumpXpLog(p.xpLog, xpEarned),
}));
}, []);
if (isSignedIn && migratedRef.current) {
getToken()
.then((token) => postJson('/api/progress/finish-review', { xpEarned, today: todayStr() }, token))
.then(setProgress)
.catch((err) => console.error('finishReview sync failed', err));
}
},
[isSignedIn, getToken],
);

return { progress, hydrated, answerQuestion, toggleSaved, finishLevel, finishReview };
};
Loading