diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 48fd889..bcb32f7 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -37,7 +37,13 @@ } ], "@clerk/expo", - "expo-secure-store" + "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "需要存取你的相簿才能選擇頭像照片" + } + ] ], "experiments": { "typedRoutes": true diff --git a/apps/mobile/app/(tabs)/notes.tsx b/apps/mobile/app/(tabs)/notes.tsx index 880ca64..4ceae8b 100644 --- a/apps/mobile/app/(tabs)/notes.tsx +++ b/apps/mobile/app/(tabs)/notes.tsx @@ -1,5 +1,130 @@ -import ComingSoon from '@/components/ComingSoon'; +import { useState } from 'react'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from '@/components/Themed'; +import { useProgress } from '@/context/ProgressContext'; +import Notes from '@/screens/Notes'; +import QuestionBook from '@/screens/QuestionBook'; +import Quiz from '@/screens/Quiz'; +import { + getSavedQuestions, + getWrongEntries, + getWrongQuestions, + REVIEW_SIZE, + sampleQuestions, + shuffle, + type Question, +} from '@easylearn/core'; + +type ViewState = + | { name: 'notes' } + | { name: 'wrongbook' } + | { name: 'savedbook' } + | { name: 'review'; questions: Question[] } + | { name: 'savedpractice'; questions: Question[] }; + +// 對照 apps/web App.tsx 裡 notes/wrongbook/savedbook/review/savedpractice 這幾支 view, +// 差別是這裡範圍只到 Notes tab,跟 Home tab 各自獨立一個狀態機(理由同 index.tsx 的註解) export default function NotesScreen() { - return ; + const { progress, hydrated, toggleSaved, answerQuestion, finishLevel, finishReview } = useProgress(); + const [view, setView] = useState({ name: 'notes' }); + const insets = useSafeAreaInsets(); + + if (!hydrated) { + return ( + + + + ); + } + + const startReview = () => { + const picked = shuffle(getWrongQuestions(progress.wrongIds)).slice(0, REVIEW_SIZE); + if (picked.length === 0) return; + setView({ name: 'review', questions: picked }); + }; + + const startSavedPractice = () => { + const picked = sampleQuestions(getSavedQuestions(progress.savedIds)); + if (picked.length === 0) return; + setView({ name: 'savedpractice', questions: picked }); + }; + + let content; + if (view.name === 'review') { + content = ( + setView({ name: 'notes' })} + exitLabel="回精選筆記" + /> + ); + } else if (view.name === 'savedpractice') { + content = ( + setView({ name: 'notes' })} + exitLabel="回精選筆記" + /> + ); + } else if (view.name === 'wrongbook') { + content = ( + setView({ name: 'notes' })} + onReview={startReview} + /> + ); + } else if (view.name === 'savedbook') { + content = ( + setView({ name: 'notes' })} + /> + ); + } else { + content = ( + setView({ name: 'wrongbook' })} + onOpenSavedBook={() => setView({ name: 'savedbook' })} + onReview={startReview} + onPracticeSaved={startSavedPractice} + /> + ); + } + + return {content}; } + +const styles = StyleSheet.create({ + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + flexFill: { + flex: 1, + }, +}); diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 824db70..41a1fef 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,20 +1,30 @@ import { useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet } from 'react-native'; +import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAuth, useSSO, useUser } from '@clerk/expo'; import { Text, View } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import AccountHeader from '@/components/AccountHeader'; +import Mascot from '@/components/Mascot'; +import GrowthHistory from '@/components/GrowthHistory'; import { useProgress } from '@/context/ProgressContext'; +import { request } from '@/lib/api'; +import { chapters, getNextStage, getStage, getStageProgress } from '@easylearn/core'; // Phase 4:不再自己打 GET /api/progress,改讀 ProgressProvider 共用的 state——跟 Home tab // 是同一份進度,登入時的搬遷(migrate-local)也只會在 provider 裡觸發一次。 +// Phase 6:補上 web 版 Profile.tsx 其餘還沒搬過來的部分(頭像拖曳/縮放/改名、吉祥物成長史、 +// 帳號設定的登出/刪除帳號)。 export default function ProfileScreen() { - const { isLoaded, isSignedIn, signOut } = useAuth(); + const { isLoaded, isSignedIn, signOut, getToken } = useAuth(); const { user } = useUser(); const { startSSOFlow } = useSSO(); const { progress, hydrated } = useProgress(); const [error, setError] = useState(null); const [signingIn, setSigningIn] = useState(false); + const [showGrowth, setShowGrowth] = useState(false); + const [deleting, setDeleting] = useState(false); const insets = useSafeAreaInsets(); const containerStyle = [styles.container, { paddingTop: insets.top }]; @@ -42,6 +52,28 @@ export default function ProfileScreen() { } }; + const handleDeleteAccount = () => { + Alert.alert('確定要刪除帳號嗎?', '此操作無法復原,雲端學習進度會一併刪除。', [ + { text: '取消', style: 'cancel' }, + { + text: '刪除帳號', + style: 'destructive', + onPress: async () => { + setDeleting(true); + try { + const token = await getToken(); + await request('/api/account', { method: 'DELETE', token }); + await signOut(); + } catch (err) { + console.error('delete account failed', err); + Alert.alert('刪除帳號失敗', '請稍後再試'); + setDeleting(false); + } + }, + }, + ]); + }; + if (!isLoaded) return null; // Android 導回 OAuth 結果時,畫面會先經過 app/sso-callback.tsx 導回這裡, @@ -58,40 +90,116 @@ export default function ProfileScreen() { if (!isSignedIn) { return ( - - 登入 EasyLearn - 登入後可在多裝置同步學習進度;未登入也能繼續刷題,進度先存在這台裝置。 - - 使用 Google 登入 - - {error && {error}} + + + + 登入 EasyLearn + 登入後可在多裝置同步學習進度;未登入也能繼續刷題,進度先存在這台裝置。 + + + 使用 Google 登入 + + {error && {error}} + ); } + const totalLevels = chapters.reduce((n, ch) => n + ch.levels.length, 0); + const doneLevels = Object.keys(progress.completedLevels).length; + const streak = progress.streak?.count ?? 0; const totalAnswered = Object.values(progress.dailyStats ?? {}).reduce((n, d) => n + d.total, 0); + const stage = getStage(progress.xp); + const nextStage = getNextStage(progress.xp); + const xpProgress = getStageProgress(progress.xp); + return ( - - - {user?.firstName ?? user?.primaryEmailAddress?.emailAddress ?? '已登入'} - - - {!hydrated && } - - {hydrated && ( - - - - - - - )} + + {!user ? ( + + ) : ( + <> + 個人資料 + + + - signOut()}> - 登出 - - + 成長史 + + + + + + {stage.name} + + {nextStage ? `${progress.xp} / ${nextStage.min} XP` : `${progress.xp} XP・已達最終型態`} + + + + + + + setShowGrowth(true)} hitSlop={8}> + 查看全部 › + + + + + setShowGrowth(false)}> + + + + 成長史 + setShowGrowth(false)} hitSlop={8}> + + + + + + + + + + + 學習統計 + {!hydrated ? ( + + ) : ( + + + + + + + )} + + 帳號設定 + + signOut()}> + + + 登出 + + + + + + + + {deleting ? '刪除中…' : '刪除帳號'} + + + + + + + )} + ); } @@ -106,11 +214,19 @@ function StatItem({ label, value }: { label: string; value: string | number }) { const styles = StyleSheet.create({ container: { + padding: 16, + gap: 14, + }, + loginContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20, - gap: 12, + }, + loginBox: { + alignItems: 'center', + gap: 10, + maxWidth: 340, }, title: { fontSize: 20, @@ -130,32 +246,108 @@ const styles = StyleSheet.create({ textAlign: 'center', }, button: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, marginTop: 12, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 10, backgroundColor: '#2e78b7', }, - signOutButton: { - backgroundColor: '#666', - }, buttonText: { color: '#fff', fontWeight: '600', }, + hero: { + borderRadius: 16, + padding: 18, + backgroundColor: '#88889910', + gap: 4, + }, + heroXpRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, + heroXpInfo: { + flex: 1, + minWidth: 0, + }, + heroXpTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'baseline', + gap: 8, + }, + heroXpName: { + fontWeight: '700', + fontSize: 13, + }, + heroXpCount: { + fontSize: 11, + opacity: 0.6, + }, + xpBar: { + height: 6, + borderRadius: 3, + backgroundColor: '#88889920', + overflow: 'hidden', + marginTop: 6, + }, + xpBarFill: { + height: '100%', + backgroundColor: '#2e78b7', + }, + growthLink: { + fontSize: 12, + fontWeight: '700', + color: '#2e78b7', + }, + modalBackdrop: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.6)', + }, + modalCard: { + maxHeight: '75%', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + backgroundColor: '#121212', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 14, + }, + modalTitle: { + fontSize: 16, + fontWeight: '700', + }, + sectionTitle: { + fontSize: 13, + fontWeight: '700', + opacity: 0.6, + marginTop: 4, + }, statGrid: { flexDirection: 'row', flexWrap: 'wrap', - justifyContent: 'center', - gap: 16, - marginTop: 8, + gap: 12, }, statItem: { + flexBasis: '47%', + flexGrow: 1, alignItems: 'center', - minWidth: 100, + borderRadius: 14, + padding: 14, + backgroundColor: '#88889910', }, statValue: { - fontSize: 22, + fontSize: 18, fontWeight: 'bold', }, statLabel: { @@ -163,4 +355,33 @@ const styles = StyleSheet.create({ opacity: 0.6, marginTop: 2, }, + accountList: { + borderRadius: 12, + overflow: 'hidden', + borderWidth: 1, + borderColor: '#88889918', + }, + accountItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 18, + borderBottomWidth: 1, + borderBottomColor: '#88889912', + }, + accountItemDanger: { + borderBottomWidth: 0, + }, + accountItemLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + accountItemText: { + fontSize: 14, + }, + accountItemDangerText: { + color: '#e5484d', + }, }); diff --git a/apps/mobile/app/(tabs)/stats.tsx b/apps/mobile/app/(tabs)/stats.tsx index 5faa95b..0c38373 100644 --- a/apps/mobile/app/(tabs)/stats.tsx +++ b/apps/mobile/app/(tabs)/stats.tsx @@ -1,5 +1,36 @@ -import ComingSoon from '@/components/ComingSoon'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { View } from '@/components/Themed'; +import { useProgress } from '@/context/ProgressContext'; +import Stats from '@/screens/Stats'; export default function StatsScreen() { - return ; + const { progress, hydrated } = useProgress(); + const insets = useSafeAreaInsets(); + + if (!hydrated) { + return ( + + + + ); + } + + return ( + + + + ); } + +const styles = StyleSheet.create({ + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + flexFill: { + flex: 1, + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 8315a08..b81a155 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -4,6 +4,7 @@ import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as WebBrowser from 'expo-web-browser'; import { useEffect } from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import 'react-native-reanimated'; @@ -55,13 +56,15 @@ export default function RootLayout() { } return ( - - - - - - - + + + + + + + + + ); } diff --git a/apps/mobile/components/AccountHeader.tsx b/apps/mobile/components/AccountHeader.tsx new file mode 100644 index 0000000..ab39369 --- /dev/null +++ b/apps/mobile/components/AccountHeader.tsx @@ -0,0 +1,405 @@ +import { useEffect, useRef, useState } from 'react'; +import { Alert, Image, Pressable, StyleSheet, TextInput, View, type NativeSyntheticEvent, type ImageLoadEventData } from 'react-native'; +import { PanGestureHandler, State, type PanGestureHandlerGestureEvent, type PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; +import Slider from '@react-native-community/slider'; +import * as ImagePicker from 'expo-image-picker'; +import { useUser } from '@clerk/expo'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; + +type ClerkUser = NonNullable['user']>; + +interface AvatarPosition { + x: number; + y: number; + scale: number; +} + +interface NaturalSize { + w: number; + h: number; +} + +// 必須跟下面 avatar 樣式的寬高一致,才能正確算出縮放後的可拖曳範圍(對照 apps/web AccountHeader.tsx) +const AVATAR_SIZE = 76; +const DEFAULT_POS: AvatarPosition = { x: 50, y: 50, scale: 1 }; +const MIN_SCALE = 1; +const MAX_SCALE = 2.5; +const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)); + +const readAvatarPosition = (metadata: unknown): AvatarPosition => { + const pos = (metadata as { avatarPosition?: Partial } | null | undefined)?.avatarPosition; + if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) { + const scale = Number.isFinite(pos.scale) ? clamp(pos.scale as number, MIN_SCALE, MAX_SCALE) : 1; + return { x: clamp(pos.x as number, 0, 100), y: clamp(pos.y as number, 0, 100), scale }; + } + return DEFAULT_POS; +}; + +// 圖片以「短邊貼齊裁切框」為基準(等同 cover),使用者的縮放倍率疊加在這個基準之上, +// 這樣兩軸的可拖曳範圍(overflow)才會隨縮放正確增加,而不是只放大裁切後的畫面 +const getOverflow = (natural: NaturalSize, scale: number) => { + const baseScale = AVATAR_SIZE / Math.min(natural.w, natural.h); + const effectiveScale = baseScale * scale; + const renderedW = natural.w * effectiveScale; + const renderedH = natural.h * effectiveScale; + return { + renderedW, + renderedH, + overflowX: Math.max(0, renderedW - AVATAR_SIZE), + overflowY: Math.max(0, renderedH - AVATAR_SIZE), + }; +}; + +// RN 的 fetch().blob() 對本機 file:// URI 常會丟出「Creating blobs from 'ArrayBuffer' and +// 'ArrayBufferView' are not supported」——RN 的 Blob 實作沒辦法從 fetch 內部轉出的 ArrayBuffer +// 建立 Blob,只有透過 XMLHttpRequest 直接以 responseType='blob' 取得的 Blob 才是原生模組認得的 +// 那種(RN 社群/Firebase Storage RN 文件都是用這個 workaround),所以這裡改用 XHR 而不是 fetch。 +const uriToBlob = (uri: string): Promise => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.onload = () => resolve(xhr.response as Blob); + xhr.onerror = () => reject(new Error('讀取照片檔案失敗')); + xhr.responseType = 'blob'; + xhr.open('GET', uri, true); + xhr.send(null); + }); + +interface AccountHeaderProps { + user: ClerkUser; +} + +// 對照 apps/web 的 AccountHeader.tsx;web 版用滑鼠 pointer 事件算拖曳量,這裡改用 +// react-native-gesture-handler 的 PanGestureHandler,translationX/Y 本來就是相對手勢起點 +// 的累計位移,跟 web 版「從 pointerdown 起點算 dx/dy」是同一個概念,換算公式整段照搬。 +export default function AccountHeader({ user }: AccountHeaderProps) { + const dragStartPos = useRef(null); + + const [pos, setPos] = useState(() => readAvatarPosition(user.unsafeMetadata)); + const [posBeforeEdit, setPosBeforeEdit] = useState(pos); + const [isRepositioning, setIsRepositioning] = useState(false); + const [uploading, setUploading] = useState(false); + const [naturalSize, setNaturalSize] = useState(null); + + const [nameEditing, setNameEditing] = useState(false); + const [nameValue, setNameValue] = useState(user.firstName ?? ''); + const [savingName, setSavingName] = useState(false); + + useEffect(() => { + // 正在拖曳/縮放調整頭像時,不能因為 Clerk user 物件因為其他更新(如改名)而重新同步, + // 蓋掉使用者還沒按「完成」儲存的操作 + if (isRepositioning) return; + setPos(readAvatarPosition(user.unsafeMetadata)); + }, [user.unsafeMetadata, isRepositioning]); + + useEffect(() => { + setNaturalSize(null); + }, [user.imageUrl]); + + useEffect(() => { + setNameValue(user.firstName ?? ''); + }, [user.firstName]); + + const saveMetadata = async (nextPos: AvatarPosition) => { + const metadata: Record = { ...user.unsafeMetadata, avatarPosition: nextPos }; + await user.update({ unsafeMetadata: metadata }); + }; + + const pickAvatar = async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + Alert.alert('沒有相簿權限', '請到系統設定開啟 EasyLearn 的相簿存取權限'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 }); + if (result.canceled || !result.assets[0]) return; + + setPosBeforeEdit(pos); + setUploading(true); + try { + const blob = await uriToBlob(result.assets[0].uri); + await user.setProfileImage({ file: blob }); + // 立刻把新照片的預設裁切位置存進 Clerk metadata:就算使用者這次調整完直接按「取消」, + // 下次讀到的也是這張新照片的預設值,不會沿用上一張照片留下的舊裁切座標 + await saveMetadata(DEFAULT_POS); + setPos(DEFAULT_POS); + setPosBeforeEdit(DEFAULT_POS); + setIsRepositioning(true); + } catch (err) { + console.error('avatar upload failed', err); + Alert.alert('上傳照片失敗', '請稍後再試'); + } finally { + setUploading(false); + } + }; + + const cancelReposition = () => { + setPos(posBeforeEdit); + setIsRepositioning(false); + }; + + const confirmReposition = async () => { + try { + await saveMetadata(pos); + setIsRepositioning(false); + } catch (err) { + console.error('save avatar position failed', err); + Alert.alert('儲存頭像位置失敗', '請稍後再試'); + } + }; + + const onGestureEvent = (event: PanGestureHandlerGestureEvent) => { + if (!isRepositioning || !naturalSize || !dragStartPos.current) return; + const startPos = dragStartPos.current; + const { overflowX, overflowY } = getOverflow(naturalSize, startPos.scale); + const { translationX, translationY } = event.nativeEvent; + const nextX = overflowX > 0 ? clamp(startPos.x + (translationX / (overflowX / 2)) * 50, 0, 100) : 50; + const nextY = overflowY > 0 ? clamp(startPos.y + (translationY / (overflowY / 2)) * 50, 0, 100) : 50; + setPos({ x: nextX, y: nextY, scale: startPos.scale }); + }; + + const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => { + if (!isRepositioning) return; + if (event.nativeEvent.state === State.BEGAN) { + dragStartPos.current = pos; + } else if ( + event.nativeEvent.state === State.END || + event.nativeEvent.state === State.CANCELLED || + event.nativeEvent.state === State.FAILED + ) { + dragStartPos.current = null; + } + }; + + const saveName = async () => { + const trimmed = nameValue.trim(); + if (!trimmed) return; + setSavingName(true); + try { + await user.update({ firstName: trimmed }); + setNameEditing(false); + } catch (err) { + console.error('save name failed', err); + Alert.alert('儲存名稱失敗', '請稍後再試'); + } finally { + setSavingName(false); + } + }; + + const cancelNameEdit = () => { + setNameValue(user.firstName ?? ''); + setNameEditing(false); + }; + + const handleImageLoad = (e: NativeSyntheticEvent) => { + const { width, height } = e.nativeEvent.source; + setNaturalSize({ w: width, h: height }); + }; + + const imgStyle = naturalSize + ? (() => { + const { renderedW, renderedH, overflowX, overflowY } = getOverflow(naturalSize, pos.scale); + const fracX = (pos.x - 50) / 50; + const fracY = (pos.y - 50) / 50; + return { + position: 'absolute' as const, + width: renderedW, + height: renderedH, + left: (AVATAR_SIZE - renderedW) / 2 + fracX * (overflowX / 2), + top: (AVATAR_SIZE - renderedH) / 2 + fracY * (overflowY / 2), + }; + })() + : { width: '100%' as const, height: '100%' as const }; + + return ( + + + + + + {user.hasImage ? ( + + ) : user.firstName ? ( + {user.firstName[0].toUpperCase()} + ) : ( + + )} + + + + + + + + + {nameEditing ? ( + + + + + + + + + + ) : ( + + {user.firstName || '未命名'} + setNameEditing(true)} hitSlop={8}> + + + + )} + + + + {isRepositioning && ( + + + setPos((prev) => ({ ...prev, scale: clamp(v, MIN_SCALE, MAX_SCALE) }))} + minimumTrackTintColor="#2e78b7" + maximumTrackTintColor="#88889940" + thumbTintColor="#ffffff" + /> + + + + + 完成 + + + + 取消 + + + 拖曳照片可移動位置,拉桿可縮放,完成後按「完成」儲存。 + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 14, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + avatarWrap: { + position: 'relative', + }, + avatar: { + width: AVATAR_SIZE, + height: AVATAR_SIZE, + borderRadius: AVATAR_SIZE / 2, + overflow: 'hidden', + backgroundColor: '#88889918', + borderWidth: 2, + borderColor: '#2e78b7', + alignItems: 'center', + justifyContent: 'center', + }, + avatarRepositioning: { + // 原本用 borderStyle:'dashed' 表示「正在調整」,但 RN 的 dashed border 疊在圓形 + // (borderRadius 50%)上是已知的平台限制,繞著曲線的虛線間距算不出來,實機上只會 + // 斷斷續續露出一小段,不是我們的樣式寫錯——改用純色框,一樣能清楚表示進入編輯模式。 + borderWidth: 3, + borderColor: '#ffb454', + }, + avatarInitial: { + fontWeight: '700', + fontSize: 26, + }, + avatarEditBtn: { + position: 'absolute', + bottom: -2, + right: -2, + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: '#2e78b7', + alignItems: 'center', + justifyContent: 'center', + }, + info: { + flex: 1, + gap: 4, + }, + nameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + name: { + fontSize: 17, + fontWeight: '700', + }, + nameEditRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + nameInput: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: '#e6e6e6', + borderWidth: 1, + borderColor: '#88889940', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + repositionPanel: { + gap: 10, + }, + zoomRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + zoomSlider: { + flex: 1, + }, + repositionActions: { + flexDirection: 'row', + gap: 16, + }, + repositionBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + repositionBtnText: { + fontSize: 12, + fontWeight: '700', + }, + repositionNote: { + fontSize: 12, + opacity: 0.6, + }, +}); diff --git a/apps/mobile/components/ComingSoon.tsx b/apps/mobile/components/ComingSoon.tsx deleted file mode 100644 index 4a70938..0000000 --- a/apps/mobile/components/ComingSoon.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { Text, View } from '@/components/Themed'; - -interface ComingSoonProps { - title: string; - phase: string; -} - -// Phase 2 只做 Profile tab,其餘分頁先放路由骨架,等對應 phase 再補內容 -export default function ComingSoon({ title, phase }: ComingSoonProps) { - const insets = useSafeAreaInsets(); - - return ( - - {title} - {phase} 還沒做,敬請期待 - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - title: { - fontSize: 20, - fontWeight: 'bold', - }, - subtitle: { - marginTop: 8, - fontSize: 14, - opacity: 0.6, - }, -}); diff --git a/apps/mobile/components/GrowthHistory.tsx b/apps/mobile/components/GrowthHistory.tsx new file mode 100644 index 0000000..3dd0d29 --- /dev/null +++ b/apps/mobile/components/GrowthHistory.tsx @@ -0,0 +1,74 @@ +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import { STAGES, getStage } from '@easylearn/core'; + +interface GrowthHistoryProps { + xp: number; +} + +// 對照 apps/web 的 GrowthHistory.tsx:吉祥物成長史清單 +export default function GrowthHistory({ xp }: GrowthHistoryProps) { + const current = getStage(xp); + + return ( + + {STAGES.map((stage) => { + const reached = xp >= stage.min; + const isCurrent = stage.name === current.name; + return ( + + {stage.emoji} + + {stage.name} + + {stage.min} XP{isCurrent ? '・目前' : ''} + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + list: { + gap: 6, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + padding: 10, + borderRadius: 10, + backgroundColor: '#88889910', + }, + itemLocked: { + opacity: 0.4, + }, + itemCurrent: { + backgroundColor: '#2e78b722', + borderWidth: 1, + borderColor: '#2e78b755', + }, + emoji: { + fontSize: 22, + width: 28, + textAlign: 'center', + }, + info: { + gap: 2, + }, + name: { + fontSize: 13, + fontWeight: '600', + }, + xp: { + fontSize: 11, + opacity: 0.55, + }, +}); diff --git a/apps/mobile/components/Icon.tsx b/apps/mobile/components/Icon.tsx index d0dbf79..554badd 100644 --- a/apps/mobile/components/Icon.tsx +++ b/apps/mobile/components/Icon.tsx @@ -1,46 +1,85 @@ -import { Text } from 'react-native'; +import type { LucideIcon } from 'lucide-react-native'; +import ArrowLeft from 'lucide-react-native/icons/arrow-left'; +import ArrowRight from 'lucide-react-native/icons/arrow-right'; +import Atom from 'lucide-react-native/icons/atom'; +import BookOpen from 'lucide-react-native/icons/book-open'; +import Bug from 'lucide-react-native/icons/bug'; +import ChartColumn from 'lucide-react-native/icons/chart-column'; +import ChevronRight from 'lucide-react-native/icons/chevron-right'; +import CircleCheck from 'lucide-react-native/icons/circle-check'; +import Clock from 'lucide-react-native/icons/clock'; +import Download from 'lucide-react-native/icons/download'; +import Eye from 'lucide-react-native/icons/eye'; +import Flag from 'lucide-react-native/icons/flag'; +import Flame from 'lucide-react-native/icons/flame'; +import House from 'lucide-react-native/icons/house'; +import Lightbulb from 'lucide-react-native/icons/lightbulb'; +import Lock from 'lucide-react-native/icons/lock'; +import LogOut from 'lucide-react-native/icons/log-out'; +import Pencil from 'lucide-react-native/icons/pencil'; +import Play from 'lucide-react-native/icons/play'; +import Rocket from 'lucide-react-native/icons/rocket'; +import RotateCcw from 'lucide-react-native/icons/rotate-ccw'; +import Search from 'lucide-react-native/icons/search'; +import Shuffle from 'lucide-react-native/icons/shuffle'; +import Sprout from 'lucide-react-native/icons/sprout'; +import Star from 'lucide-react-native/icons/star'; +import Trash2 from 'lucide-react-native/icons/trash-2'; +import Trophy from 'lucide-react-native/icons/trophy'; +import User from 'lucide-react-native/icons/user'; +import X from 'lucide-react-native/icons/x'; import type { IconName } from '@easylearn/core'; +import Colors from '@/constants/Colors'; +import { useColorScheme } from '@/components/useColorScheme'; -// Phase 3 MVP:先用 emoji 頂替 web 版的 SVG 圖示(apps/web/src/components/Icons.tsx), -// 求功能完整(離線可以跑完整答題流程),不追求跟 web 像素級一致的視覺。 -// 之後要做「星際 HUD」視覺時再換成真的向量圖示。 -const EMOJI = { - 'arrow-left': '←', - 'arrow-right': '→', - x: '✕', - 'chevron-right': '›', - lock: '🔒', - 'check-circle': '✅', - play: '▶️', - sprout: '🌱', - rocket: '🚀', - atom: '⚛️', - eye: '👁️', - bug: '🐛', - search: '🔍', - pencil: '✏️', - 'book-open': '📖', - lightbulb: '💡', - flag: '🏁', - trophy: '🏆', - flame: '🔥', - 'rotate-ccw': '↺', - download: '⬇️', - star: '⭐', - home: '🏠', - clock: '🕐', - 'bar-chart': '📊', - user: '👤', - shuffle: '🔀', - logout: '🚪', - trash: '🗑️', -} satisfies Record; +// Phase 6:換成跟 tab bar 一致的線型簡約圖示,取代 Phase 3 MVP 暫時頂替用的 emoji。 +// lucide-react-native 是 apps/web Icons.tsx 那組線型圖示(lucide.dev)的官方 RN 版本, +// 圖示名稱/路徑跟 web 版是同一個來源,只有少數幾個 icon 在較新版本改了名字 +// (check-circle→CircleCheck、home→House、bar-chart→ChartColumn),對照確認過路徑資料一致。 +// 用 `lucide-react-native/icons/xxx` 逐一深層匯入,不用根套件的具名匯入:根套件是全部 +// 3000+ 個圖示的 barrel re-export,Metro 對 barrel import 的 tree-shaking 不夠乾淨, +// 實測 `expo export --platform web` 這樣會整包 3000+ 圖示一起打包(bundle 從 3.1MB +// 漲到 5.3MB);換成逐一深層匯入後只打包這裡真的用到的 29 個。 +const ICONS: Record = { + 'arrow-left': ArrowLeft, + 'arrow-right': ArrowRight, + x: X, + 'chevron-right': ChevronRight, + lock: Lock, + 'check-circle': CircleCheck, + play: Play, + sprout: Sprout, + rocket: Rocket, + atom: Atom, + eye: Eye, + bug: Bug, + search: Search, + pencil: Pencil, + 'book-open': BookOpen, + lightbulb: Lightbulb, + flag: Flag, + trophy: Trophy, + flame: Flame, + 'rotate-ccw': RotateCcw, + download: Download, + star: Star, + home: House, + clock: Clock, + 'bar-chart': ChartColumn, + user: User, + shuffle: Shuffle, + logout: LogOut, + trash: Trash2, +}; interface IconProps { name: IconName; size?: number; + color?: string; } -export default function Icon({ name, size = 18 }: IconProps) { - return {EMOJI[name]}; +export default function Icon({ name, size = 18, color }: IconProps) { + const theme = useColorScheme(); + const IconComponent = ICONS[name]; + return ; } diff --git a/apps/mobile/components/Mascot.tsx b/apps/mobile/components/Mascot.tsx new file mode 100644 index 0000000..b73cec0 --- /dev/null +++ b/apps/mobile/components/Mascot.tsx @@ -0,0 +1,71 @@ +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import { getNextStage, getStage, getStageProgress } from '@easylearn/core'; + +interface MascotProps { + xp: number; + size?: 'lg' | 'sm'; +} + +// 對照 apps/web 的 Mascot.tsx;沒有搬 mood="happy" 的彈跳動畫,Phase 3 CodeBlock/Icon +// 已經確立「先求功能完整」的簡化原則,這裡先只做靜態顯示 +export default function Mascot({ xp, size = 'lg' }: MascotProps) { + const stage = getStage(xp); + const next = getNextStage(xp); + const progressToNext = getStageProgress(xp); + + return ( + + {stage.emoji} + {size === 'lg' && ( + <> + {stage.name} + + + + + {next ? `${xp} XP・再 ${next.min - xp} XP 進化成 ${next.emoji}` : `${xp} XP・已達最終型態!`} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + emoji: { + fontSize: 72, + lineHeight: 84, + }, + emojiSm: { + fontSize: 32, + lineHeight: 38, + }, + name: { + fontWeight: '700', + fontSize: 15, + marginTop: 4, + }, + xpBar: { + width: 200, + height: 10, + borderRadius: 5, + backgroundColor: '#88889920', + overflow: 'hidden', + marginTop: 8, + }, + xpBarFill: { + height: '100%', + backgroundColor: '#2e78b7', + }, + hint: { + fontSize: 12, + opacity: 0.6, + marginTop: 6, + textAlign: 'center', + }, +}); diff --git a/apps/mobile/components/useColorScheme.ts b/apps/mobile/components/useColorScheme.ts index 4b95f58..1095102 100644 --- a/apps/mobile/components/useColorScheme.ts +++ b/apps/mobile/components/useColorScheme.ts @@ -1,6 +1,4 @@ -import { useColorScheme as useColorSchemeCore } from 'react-native'; - +// 目前整個 app 先強制走深色主題(不看裝置系統設定),Phase 5/6 做完整視覺前的過渡措施 export const useColorScheme = () => { - const coreScheme = useColorSchemeCore(); - return coreScheme === 'unspecified' || coreScheme === null ? 'light' : coreScheme; + return 'dark' as const; }; diff --git a/apps/mobile/components/useColorScheme.web.ts b/apps/mobile/components/useColorScheme.web.ts index 6dcd80d..63a28a0 100644 --- a/apps/mobile/components/useColorScheme.web.ts +++ b/apps/mobile/components/useColorScheme.web.ts @@ -1,8 +1,4 @@ -// NOTE: The default React Native styling doesn't support server rendering. -// Server rendered styles should not change between the first render of the HTML -// and the first render on the client. Typically, web developers will use CSS media queries -// to render different styles on the client and server, these aren't directly supported in React Native -// but can be achieved using a styling library like Nativewind. +// 目前整個 app 先強制走深色主題(不看裝置系統設定),Phase 5/6 做完整視覺前的過渡措施 export function useColorScheme() { - return 'light'; + return 'dark'; } diff --git a/apps/mobile/constants/Colors.ts b/apps/mobile/constants/Colors.ts index 1c706c7..ac788c4 100644 --- a/apps/mobile/constants/Colors.ts +++ b/apps/mobile/constants/Colors.ts @@ -10,10 +10,10 @@ export default { tabIconSelected: tintColorLight, }, dark: { - text: '#fff', - background: '#000', + text: '#e6e6e6', + background: '#121212', tint: tintColorDark, - tabIconDefault: '#ccc', + tabIconDefault: '#888', tabIconSelected: tintColorDark, }, }; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 46ce61e..4d07625 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "expo-status-bar": "~57.0.0", "expo-symbols": "~57.0.0", "expo-web-browser": "~57.0.0", + "lucide-react-native": "^1.24.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", diff --git a/apps/mobile/screens/Notes.tsx b/apps/mobile/screens/Notes.tsx new file mode 100644 index 0000000..3cecbb3 --- /dev/null +++ b/apps/mobile/screens/Notes.tsx @@ -0,0 +1,110 @@ +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import { getWrongQuestions, type Progress } from '@easylearn/core'; + +interface NotesProps { + progress: Progress; + onOpenWrongBook: () => void; + onOpenSavedBook: () => void; + onReview: () => void; + onPracticeSaved: () => void; +} + +// 對照 apps/web 的 Notes.tsx:錯題本/收藏題庫兩張入口卡 +export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onReview, onPracticeSaved }: NotesProps) { + const wrongCount = getWrongQuestions(progress.wrongIds ?? {}).length; + const savedCount = Object.keys(progress.savedIds ?? {}).length; + + return ( + + + + + 錯題本 + + 目前累積 {wrongCount} 題錯題 + { + e.stopPropagation(); + onReview(); + }} + > + 開始複習 + + + + + + + 收藏題庫 + + 目前累積 {savedCount} 題收藏 + { + e.stopPropagation(); + onPracticeSaved(); + }} + > + {savedCount === 0 ? '無收藏題' : '開始練習'} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + gap: 16, + }, + card: { + borderRadius: 16, + padding: 20, + gap: 4, + borderWidth: 1, + }, + cardWrong: { + backgroundColor: '#e5484d14', + borderColor: '#e5484d40', + }, + cardSaved: { + backgroundColor: '#2e78b714', + borderColor: '#2e78b740', + }, + cardHead: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cardTitle: { + fontSize: 16, + fontWeight: '700', + }, + cardCount: { + fontSize: 13, + opacity: 0.7, + marginTop: 6, + marginBottom: 14, + }, + cardBtn: { + alignSelf: 'flex-start', + borderWidth: 1, + borderColor: '#88889940', + borderRadius: 10, + paddingVertical: 10, + paddingHorizontal: 20, + }, + cardBtnDisabled: { + opacity: 0.5, + }, + cardBtnText: { + fontSize: 13, + fontWeight: '700', + }, +}); diff --git a/apps/mobile/screens/QuestionBook.tsx b/apps/mobile/screens/QuestionBook.tsx new file mode 100644 index 0000000..8d7a6e4 --- /dev/null +++ b/apps/mobile/screens/QuestionBook.tsx @@ -0,0 +1,102 @@ +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import QuestionReview from '@/screens/QuestionReview'; +import type { Question, WrongEntry } from '@easylearn/core'; + +interface QuestionBookProps { + kind: 'wrong' | 'saved'; + entries: WrongEntry[] | Question[]; + savedIds: Record; + onToggleSave: (questionId: string) => void; + onBack: () => void; + onReview?: () => void; +} + +// 錯題本/收藏題目共用的清單畫面(對照 apps/web 同名畫面) +export default function QuestionBook({ kind, entries, savedIds, onToggleSave, onBack, onReview }: QuestionBookProps) { + const isWrong = kind === 'wrong'; + const title = isWrong ? '錯題本' : '收藏題目'; + + return ( + + + + + + {title} + + + {isWrong && entries.length > 0 && ( + + + 開始重練({entries.length} 題) + + )} + + {entries.length === 0 ? ( + {isWrong ? '目前沒有錯題,太厲害了!' : '還沒有收藏的題目,答題時點星號收藏吧!'} + ) : ( + + {entries.map((item) => { + const question = isWrong ? (item as WrongEntry).question : (item as Question); + return ( + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + gap: 10, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: 6, + }, + backBtn: { + padding: 4, + }, + title: { + fontSize: 18, + fontWeight: '700', + }, + reviewBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + borderWidth: 1, + borderColor: '#2e78b755', + borderRadius: 12, + paddingVertical: 13, + marginBottom: 4, + }, + reviewBtnText: { + fontWeight: '700', + fontSize: 14, + }, + empty: { + textAlign: 'center', + opacity: 0.6, + fontSize: 14, + marginTop: 40, + }, + list: { + gap: 16, + }, +}); diff --git a/apps/mobile/screens/QuestionReview.tsx b/apps/mobile/screens/QuestionReview.tsx new file mode 100644 index 0000000..575359d --- /dev/null +++ b/apps/mobile/screens/QuestionReview.tsx @@ -0,0 +1,164 @@ +import { Linking, Pressable, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import CodeBlock from '@/components/CodeBlock'; +import { TYPE_META, type Question, type WrongEntryMeta } from '@easylearn/core'; + +interface QuestionReviewProps { + question: Question; + saved: boolean; + onToggleSave: (questionId: string) => void; + meta: WrongEntryMeta | null; +} + +// 唯讀題目卡:錯題本/收藏瀏覽頁用,直接看答案與解釋,不用重考(對照 apps/web 同名元件) +export default function QuestionReview({ question, saved, onToggleSave, meta }: QuestionReviewProps) { + const typeMeta = TYPE_META[question.type]; + + return ( + + + + + {typeMeta.label} + + + {question.topic} + + onToggleSave(question.id)} hitSlop={8} style={{ opacity: saved ? 1 : 0.35 }}> + + + + + {meta && ( + + 答錯 {meta.count} 次 + {meta.lastWrong && 最近答錯:{meta.lastWrong}} + + )} + + {question.prompt} + + + + {question.options.map((opt) => ( + + {opt.text} + + ))} + + + + + + 解釋 + + {question.explanation} + {question.docs && ( + Linking.openURL(question.docs).catch(() => {})}> + 延伸閱讀:官方文件 + + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + gap: 4, + borderRadius: 14, + padding: 16, + backgroundColor: '#88889910', + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: 8, + }, + typeBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: '#2e78b722', + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + typeBadgeText: { + fontSize: 12, + fontWeight: '600', + }, + topicLabel: { + flex: 1, + fontSize: 12, + opacity: 0.6, + }, + reviewMetaRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + marginBottom: 6, + }, + reviewMetaText: { + fontSize: 11, + opacity: 0.55, + }, + prompt: { + fontSize: 16, + fontWeight: '600', + lineHeight: 22, + }, + options: { + gap: 8, + marginTop: 6, + }, + optionBtn: { + borderWidth: 1, + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 14, + }, + optionCorrect: { + borderColor: '#2f9e44', + backgroundColor: '#2f9e4422', + }, + optionDimmed: { + borderColor: '#8888991a', + backgroundColor: '#88889908', + opacity: 0.5, + }, + optionText: { + fontSize: 14, + }, + feedback: { + marginTop: 12, + borderRadius: 10, + padding: 14, + gap: 8, + backgroundColor: '#2f9e4418', + }, + feedbackTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + feedbackTitle: { + fontSize: 15, + fontWeight: '700', + }, + feedbackExplanation: { + fontSize: 14, + lineHeight: 20, + }, + docsLink: { + fontSize: 13, + color: '#2e78b7', + textDecorationLine: 'underline', + }, +}); diff --git a/apps/mobile/screens/Quiz.tsx b/apps/mobile/screens/Quiz.tsx index e5e109a..9b50062 100644 --- a/apps/mobile/screens/Quiz.tsx +++ b/apps/mobile/screens/Quiz.tsx @@ -105,7 +105,7 @@ export default function Quiz({ {isReview && questions.some((q) => progress.wrongIds[q.id]) - ? '還沒畢業的錯題會留著,繼續練會更熟練!' + ? '答對一次就會從錯題本移除,還沒答對的之後可以再挑戰!' : '你的星球又長大了一點,明天也要回來澆灌它喔!'} @@ -136,7 +136,7 @@ export default function Quiz({ {isReview && ( - 錯題重練模式:答對升熟練度,答錯重來,練到最高熟練度才畢業 + 錯題重練模式:答對一次就從錯題本移除,答錯會留到下次 )} {isMixed && ( diff --git a/apps/mobile/screens/Stats.tsx b/apps/mobile/screens/Stats.tsx new file mode 100644 index 0000000..fef4c19 --- /dev/null +++ b/apps/mobile/screens/Stats.tsx @@ -0,0 +1,446 @@ +import { useRef } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import { chapters, todayStr, type Progress } from '@easylearn/core'; + +const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']; + +const getLast7Dates = (): Date[] => { + const today = new Date(); + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(today); + d.setDate(today.getDate() - (6 - i)); + return d; + }); +}; + +// 熱力圖範圍:近 26 週(約半年),週日為每欄第一天,補到今天所在週的週六(未來日期補 null 佔位) +const HEATMAP_WEEKS = 26; + +const getHeatmapDays = (): (string | null)[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const totalDays = HEATMAP_WEEKS * 7; + const start = new Date(today); + start.setDate(start.getDate() - (totalDays - 1)); + start.setDate(start.getDate() - start.getDay()); + const days: (string | null)[] = []; + const cursor = new Date(start); + while (cursor <= today) { + days.push(cursor.toLocaleDateString('en-CA')); + cursor.setDate(cursor.getDate() + 1); + } + while (days.length % 7 !== 0) days.push(null); + return days; +}; + +const chunkIntoWeeks = (days: (string | null)[]): (string | null)[][] => { + const weeks: (string | null)[][] = []; + for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7)); + return weeks; +}; + +// 依做題量分 5 級(0 = 沒作答,1~4 依量遞增),對照 apps/web Stats.tsx 同名函式 +const activityLevel = (total: number): number => { + if (total <= 0) return 0; + if (total < 4) return 1; + if (total < 8) return 2; + if (total < 15) return 3; + return 4; +}; + +const HEAT_COLORS = ['#88889912', '#2e78b730', '#2e78b760', '#2e78b790', '#2e78b7']; + + +interface StatsProps { + progress: Progress; +} + +// 對照 apps/web 的 Stats.tsx;熱力圖/迷你長條圖沒有 hover title,觸控裝置沒有對應行為, +// 是刻意的簡化(跟 Phase 3 CodeBlock/Icon 的 MVP 簡化同一個原則) +export default function Stats({ progress }: StatsProps) { + const dailyStats = progress.dailyStats ?? {}; + const chapterStats = progress.chapterStats ?? {}; + const heatmapScrollRef = useRef(null); + + const totals = Object.values(dailyStats).reduce( + (acc, d) => ({ total: acc.total + d.total, correct: acc.correct + d.correct }), + { total: 0, correct: 0 }, + ); + const avgAccuracy = totals.total ? Math.round((totals.correct / totals.total) * 100) : 0; + + const days = getLast7Dates().map((d) => { + const key = d.toLocaleDateString('en-CA'); + const stat = dailyStats[key] ?? { total: 0, correct: 0 }; + const accuracy = stat.total ? Math.round((stat.correct / stat.total) * 100) : 0; + return { key, label: WEEKDAY_LABELS[d.getDay()], total: stat.total, accuracy, isToday: key === todayStr() }; + }); + const maxCount = Math.max(1, ...days.map((d) => d.total)); + + const heatmapWeeks = chunkIntoWeeks(getHeatmapDays()); + let lastMonth = -1; + const heatmapMonthLabels = heatmapWeeks.map((week) => { + const firstReal = week.find((d): d is string => d !== null); + const month = firstReal ? new Date(firstReal).getMonth() : lastMonth; + const show = firstReal !== undefined && month !== lastMonth; + lastMonth = month; + return show ? String(month + 1) : ''; + }); + + return ( + + + + 總答題數 + {totals.total} 題 + + + 平均正確率 + {avgAccuracy}% + + + + 累計答對題數 + + + {totals.correct} 題 + + + + 近半年學習熱力圖 + + + + + {WEEKDAY_LABELS.map((label, i) => ( + + {i % 2 === 1 ? label : ''} + + ))} + + heatmapScrollRef.current?.scrollToEnd({ animated: false })} + > + + + {heatmapMonthLabels.map((label, i) => ( + + {label} + + ))} + + + {heatmapWeeks.map((week, wi) => ( + + {week.map((date, di) => + date ? ( + + ) : ( + + ), + )} + + ))} + + + + + + + {HEAT_COLORS.map((color, l) => ( + + ))} + + + + + 近 7 日學習進度 + + + + + 做題量 + + + {days.map((d) => ( + + {d.total > 0 ? d.total : ''} + + {d.label} + + ))} + + + + + + 正確率 + + + {days.map((d) => ( + + {d.total > 0 ? `${d.accuracy}%` : ''} + 0 ? Math.max(4, (d.accuracy / 100) * 72) : 2, + }, + ]} + /> + {d.label} + + ))} + + + + + 分科成效細分 + + 這裡是該章節所有作答的答對率(含隨機綜合練習),跟「每日刷題」頁的完成關卡數是不同的統計方式。 + + + {chapters.map((ch) => { + const stat = chapterStats[ch.id] ?? { total: 0, correct: 0 }; + const pct = stat.total ? Math.round((stat.correct / stat.total) * 100) : 0; + return ( + + + {ch.title} + {pct}% + + + + + + 答對 {stat.correct} / 總答 {stat.total} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + gap: 12, + }, + tileRow: { + flexDirection: 'row', + gap: 12, + }, + tile: { + flex: 1, + borderRadius: 14, + padding: 16, + backgroundColor: '#88889910', + gap: 8, + }, + tileWide: { + borderRadius: 14, + padding: 16, + backgroundColor: '#2e78b714', + gap: 8, + }, + tileWideValueRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + tileLabel: { + fontSize: 11, + opacity: 0.6, + }, + tileValue: { + fontSize: 24, + fontWeight: '800', + }, + sectionTitle: { + fontSize: 13, + fontWeight: '700', + opacity: 0.6, + marginTop: 10, + }, + sectionHint: { + fontSize: 11, + opacity: 0.5, + marginTop: -4, + lineHeight: 16, + }, + heatmapCard: { + borderRadius: 14, + padding: 14, + backgroundColor: '#88889910', + }, + heatmapRow: { + flexDirection: 'row', + gap: 6, + }, + heatmapWeekdayCol: { + gap: 3, + }, + heatmapMonthSpacer: { + height: 14, + }, + heatmapWeekday: { + height: 11, + lineHeight: 11, + fontSize: 9, + opacity: 0.55, + }, + heatmapMonths: { + flexDirection: 'row', + gap: 3, + height: 14, + }, + heatmapMonthLabel: { + // 只顯示月份數字(不含「月」字),16px 寬夠放到 2 位數("12")也不會被 Text 自己的 + // 換行邏輯裁掉——先前用「讓文字視覺溢出方框」的寫法在這裡的水平 ScrollView 裡不可靠 + // (超出 ScrollView 量到的內容寬度那段一樣會被裁掉,尤其是捲到最右邊、沒有下一欄可以 + // 借用空間的最後一個月份),所以改成單純夠寬的固定寬度,不依賴溢出。 + width: 16, + fontSize: 9, + opacity: 0.55, + }, + heatmapGrid: { + flexDirection: 'row', + gap: 3, + }, + heatmapCol: { + width: 16, + alignItems: 'center', + gap: 3, + }, + heatmapCell: { + width: 11, + height: 11, + borderRadius: 2, + }, + heatmapCellBlank: { + width: 11, + height: 11, + }, + heatmapLegend: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + marginTop: 10, + }, + heatmapLegendText: { + fontSize: 10, + opacity: 0.55, + }, + miniChartPair: { + flexDirection: 'row', + gap: 16, + borderRadius: 14, + padding: 16, + backgroundColor: '#88889910', + }, + miniChart: { + flex: 1, + }, + miniChartTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 10, + }, + miniChartTitle: { + fontSize: 12, + opacity: 0.7, + }, + legendDot: { + width: 8, + height: 8, + borderRadius: 2, + }, + miniChartBars: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: 4, + height: 108, + }, + miniBarCol: { + flex: 1, + alignItems: 'center', + justifyContent: 'flex-end', + height: '100%', + }, + miniBarValue: { + fontSize: 10, + opacity: 0.5, + height: 14, + }, + miniBar: { + width: '60%', + minWidth: 6, + borderRadius: 2, + }, + miniBarLabel: { + fontSize: 10, + opacity: 0.5, + marginTop: 6, + }, + miniBarLabelToday: { + opacity: 1, + fontWeight: '700', + }, + chapterList: { + gap: 12, + }, + chapterCard: { + borderRadius: 14, + padding: 16, + backgroundColor: '#88889910', + gap: 8, + }, + chapterCardHead: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + chapterCardTitle: { + fontSize: 14, + fontWeight: '700', + }, + chapterCardPct: { + fontSize: 14, + fontWeight: '700', + }, + chapterBar: { + height: 6, + borderRadius: 3, + backgroundColor: '#88889925', + overflow: 'hidden', + }, + chapterBarFill: { + height: '100%', + backgroundColor: '#2f9e44', + }, + chapterCardSub: { + fontSize: 11, + opacity: 0.55, + }, +}); diff --git a/apps/web/src/app/api/progress/answer/route.ts b/apps/web/src/app/api/progress/answer/route.ts index a5ee639..fe52267 100644 --- a/apps/web/src/app/api/progress/answer/route.ts +++ b/apps/web/src/app/api/progress/answer/route.ts @@ -1,7 +1,6 @@ import type { Prisma } from '@prisma/client' import { auth } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' -import { GRADUATE_BOX } from '@easylearn/core' import { prisma } from '@/lib/prisma' import { isValidDateStr } from '@/lib/progressLogic' import { loadFullProgress } from '@/lib/progressStore' @@ -15,8 +14,8 @@ interface AnswerBody { today: string } -// 答題後更新 Leitner 錯題本+每日/分科作答統計,規則跟 useProgress.ts 的 answerQuestion 一致: -// 答錯→記入或重置回第 1 盒;答對→有錯題紀錄才升一盒,超過畢業盒就移出錯題本 +// 答題後更新錯題本+每日/分科作答統計,規則跟 useProgress.ts 的 answerQuestion 一致: +// 答錯→記入或重置錯題紀錄;答對→只要這題還在錯題本裡就直接刪除(答對一次就畢業) export const POST = async (request: Request) => { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) @@ -39,14 +38,7 @@ export const POST = async (request: Request) => { // 每個分支各自算出自己的 0 或 1 筆寫入,最後展開組成同一份交易清單 const wrongEntryWrites: Prisma.PrismaPromise[] = correct ? existing - ? [ - existing.box + 1 > GRADUATE_BOX - ? prisma.wrongEntry.delete({ where: { userId_questionId: { userId, questionId } } }) - : prisma.wrongEntry.update({ - where: { userId_questionId: { userId, questionId } }, - data: { box: existing.box + 1 }, - }), - ] + ? [prisma.wrongEntry.delete({ where: { userId_questionId: { userId, questionId } } })] : [] : [ prisma.wrongEntry.upsert({ diff --git a/apps/web/src/components/GrowthHistory.tsx b/apps/web/src/components/GrowthHistory.tsx index dc26e7d..915089b 100644 --- a/apps/web/src/components/GrowthHistory.tsx +++ b/apps/web/src/components/GrowthHistory.tsx @@ -1,4 +1,4 @@ -import { STAGES, getStage } from '@/lib/stages' +import { STAGES, getStage } from '@easylearn/core' interface GrowthHistoryProps { xp: number diff --git a/apps/web/src/components/Mascot.tsx b/apps/web/src/components/Mascot.tsx index 3a8591a..61ec400 100644 --- a/apps/web/src/components/Mascot.tsx +++ b/apps/web/src/components/Mascot.tsx @@ -1,4 +1,4 @@ -import { getNextStage, getStage } from '@/lib/stages' +import { getNextStage, getStage, getStageProgress } from '@easylearn/core' interface MascotProps { xp: number @@ -9,9 +9,7 @@ interface MascotProps { const Mascot = ({ xp, mood = 'idle', size = 'lg' }: MascotProps) => { const stage = getStage(xp) const next = getNextStage(xp) - const progressToNext = next - ? Math.min(100, Math.round(((xp - stage.min) / (next.min - stage.min)) * 100)) - : 100 + const progressToNext = getStageProgress(xp) return (
diff --git a/apps/web/src/components/QuestionReview.tsx b/apps/web/src/components/QuestionReview.tsx index 1fae4d6..3463cfa 100644 --- a/apps/web/src/components/QuestionReview.tsx +++ b/apps/web/src/components/QuestionReview.tsx @@ -1,6 +1,6 @@ import CodeBlock from '@/components/CodeBlock' import Icon from '@/components/Icons' -import { GRADUATE_BOX, TYPE_META, type Question, type WrongEntryMeta } from '@easylearn/core' +import { TYPE_META, type Question, type WrongEntryMeta } from '@easylearn/core' interface QuestionReviewProps { question: Question @@ -33,9 +33,6 @@ const QuestionReview = ({ question, saved, onToggleSave, meta }: QuestionReviewP {meta && (
答錯 {meta.count} 次 - - 熟練度 {meta.box}/{GRADUATE_BOX} - {meta.lastWrong && 最近答錯:{meta.lastWrong}}
)} diff --git a/apps/web/src/hooks/useProgress.ts b/apps/web/src/hooks/useProgress.ts index 382dc4d..c5476a5 100644 --- a/apps/web/src/hooks/useProgress.ts +++ b/apps/web/src/hooks/useProgress.ts @@ -15,7 +15,8 @@ const defaultProgress: Progress = { xp: 0, // { [levelId]: { best: 答對題數, total: 題數 } } completedLevels: {}, - // 錯題本:{ [questionId]: { count: 答錯次數, lastWrong: 'YYYY-MM-DD', box: 熟練度 1~GRADUATE_BOX } } + // 錯題本:{ [questionId]: { count: 答錯次數, lastWrong: 'YYYY-MM-DD', box } }(box 是舊版 Leitner + // 盒制留下的欄位,現在答對一次就直接移出錯題本,box 不再影響移出時機,維持型別/DB 相容) wrongIds: {}, // 收藏題目:{ [questionId]: true } savedIds: {}, @@ -113,8 +114,7 @@ export const useProgress = () => { })() }, [isLoaded, isSignedIn]) - // 每答一題就更新錯題本(Leitner 盒制): - // 答錯 → 記入/重置回第 1 盒;答對 → 升一盒,超過畢業盒才移出錯題本 + // 每答一題就更新錯題本:答錯 → 記入/重置錯題紀錄;答對 → 這題若在錯題本裡就直接移出 // 同時累計每日/分科作答統計,供學習數據頁使用 const answerQuestion = (questionId: string, correct: boolean, chapterId?: string) => { setProgress((p) => applyAnswer(p, questionId, correct, chapterId)) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index be93932..ce6e57b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -263,6 +263,13 @@ button { margin: 24px 0 12px; } +.section-hint { + margin: -8px 0 12px; + font-size: 12px; + line-height: 1.6; + color: var(--ink-faint); +} + /* ── 首頁 ─────────────────────────── */ .app-title { font-family: var(--font-mono); diff --git a/apps/web/src/screens/Profile.tsx b/apps/web/src/screens/Profile.tsx index 3edcb06..6a858cf 100644 --- a/apps/web/src/screens/Profile.tsx +++ b/apps/web/src/screens/Profile.tsx @@ -4,8 +4,7 @@ import AccountHeader from '@/components/AccountHeader' import GrowthHistory from '@/components/GrowthHistory' import Mascot from '@/components/Mascot' import Icon from '@/components/Icons' -import { getStage, getNextStage } from '@/lib/stages' -import { chapters, type Progress } from '@easylearn/core' +import { chapters, getStage, getNextStage, type Progress } from '@easylearn/core' interface ProfileProps { progress: Progress diff --git a/apps/web/src/screens/Quiz.tsx b/apps/web/src/screens/Quiz.tsx index 045051c..4198c4b 100644 --- a/apps/web/src/screens/Quiz.tsx +++ b/apps/web/src/screens/Quiz.tsx @@ -2,8 +2,7 @@ import { useState } from 'react' import QuestionCard from '@/components/QuestionCard' import Mascot from '@/components/Mascot' import Icon from '@/components/Icons' -import { getStage } from '@/lib/stages' -import { getChapterIdForQuestion, type Level, type Progress } from '@easylearn/core' +import { getChapterIdForQuestion, getStage, type Level, type Progress } from '@easylearn/core' const XP_CORRECT = 10 const XP_WRONG = 2 @@ -100,7 +99,7 @@ const Quiz = ({

{isReview && questions.some((q) => progress.wrongIds[q.id]) - ? '還沒畢業的錯題會留著,繼續練會更熟練!' + ? '答對一次就會從錯題本移除,還沒答對的之後可以再挑戰!' : '你的星球又長大了一點,明天也要回來澆灌它喔!'}