From 98c3923242b32d5ac6f24e17baa9b3a6fe6991df Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:01:39 +0800 Subject: [PATCH 1/8] =?UTF-8?q?apps/mobile=20=E5=BC=B7=E5=88=B6=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A1=8C=EF=BC=8C=E4=BF=AE=E5=BE=A9=E9=9D=9E?= =?UTF-8?q?=20emoji=20=E5=9C=96=E7=A4=BA=E5=9C=A8=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E5=BA=95=E4=B8=8B=E7=9C=8B=E4=B8=8D=E8=A6=8B=E7=9A=84=E5=95=8F?= =?UTF-8?q?=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RN 重構過程中淺色主題太刺眼,先把 useColorScheme 鎖死回傳 dark(不看裝置系統設定), Colors.dark 改用柔和的 #121212/#e6e6e6 取代刺眼的純黑/純白。 Icon.tsx 原本用 react-native 原生 Text 顯示非 emoji 符號(✕←→›↺),預設黑字在深色背景 幾乎全隱形,改成從 Themed 匯入 Text 讓它跟著主題文字色走。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/components/Icon.tsx | 2 +- apps/mobile/components/useColorScheme.ts | 6 ++---- apps/mobile/components/useColorScheme.web.ts | 8 ++------ apps/mobile/constants/Colors.ts | 6 +++--- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/mobile/components/Icon.tsx b/apps/mobile/components/Icon.tsx index d0dbf79..9dd975a 100644 --- a/apps/mobile/components/Icon.tsx +++ b/apps/mobile/components/Icon.tsx @@ -1,4 +1,4 @@ -import { Text } from 'react-native'; +import { Text } from '@/components/Themed'; import type { IconName } from '@easylearn/core'; // Phase 3 MVP:先用 emoji 頂替 web 版的 SVG 圖示(apps/web/src/components/Icons.tsx), 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, }, }; From af0d24570e3f066985ed97207e618b06347142a2 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:08:01 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20apps/mobile=20Notes/St?= =?UTF-8?q?ats=20tab=EF=BC=9A=E9=8C=AF=E9=A1=8C=E6=9C=AC=E3=80=81=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E9=A1=8C=E5=BA=AB=E3=80=81=E5=AD=B8=E7=BF=92=E7=86=B1?= =?UTF-8?q?=E5=8A=9B=E5=9C=96=EF=BC=88Phase=205=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 對照 apps/web 的 Notes/QuestionBook/Stats 畫面與 QuestionReview 元件,複用 packages/core 既有的 getWrongQuestions/getWrongEntries/getSavedQuestions 等函式。 Notes tab 比照 Home tab 用 view 狀態機切換 notes/wrongbook/savedbook/review/ savedpractice,review 與 savedpractice 直接複用既有的 Quiz 元件。Stats tab 是 單一唯讀畫面,熱力圖改用 RN ScrollView horizontal。刪除不再使用的 ComingSoon.tsx。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/app/(tabs)/notes.tsx | 129 +++++++- apps/mobile/app/(tabs)/stats.tsx | 35 +- apps/mobile/components/ComingSoon.tsx | 39 --- apps/mobile/screens/Notes.tsx | 110 +++++++ apps/mobile/screens/QuestionBook.tsx | 102 ++++++ apps/mobile/screens/QuestionReview.tsx | 167 ++++++++++ apps/mobile/screens/Stats.tsx | 425 +++++++++++++++++++++++++ docs/rn-migration.md | 37 ++- 8 files changed, 1000 insertions(+), 44 deletions(-) delete mode 100644 apps/mobile/components/ComingSoon.tsx create mode 100644 apps/mobile/screens/Notes.tsx create mode 100644 apps/mobile/screens/QuestionBook.tsx create mode 100644 apps/mobile/screens/QuestionReview.tsx create mode 100644 apps/mobile/screens/Stats.tsx 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)/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/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/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..f7e85e0 --- /dev/null +++ b/apps/mobile/screens/QuestionReview.tsx @@ -0,0 +1,167 @@ +import { Linking, Pressable, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import Icon from '@/components/Icon'; +import CodeBlock from '@/components/CodeBlock'; +import { GRADUATE_BOX, 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.box}/{GRADUATE_BOX} + + {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/Stats.tsx b/apps/mobile/screens/Stats.tsx new file mode 100644 index 0000000..0a13145 --- /dev/null +++ b/apps/mobile/screens/Stats.tsx @@ -0,0 +1,425 @@ +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']; + +const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + +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 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 ? MONTH_LABELS[month] : ''; + }); + + return ( + + + + 總答題數 + {totals.total} 題 + + + 平均正確率 + {avgAccuracy}% + + + + 累計答對題數 + + + {totals.correct} 題 + + + + 近半年學習熱力圖 + + + + + {WEEKDAY_LABELS.map((label, i) => ( + + {i % 2 === 1 ? label : ''} + + ))} + + + + + {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, + }, + 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: { + width: 11, + fontSize: 9, + opacity: 0.55, + }, + heatmapGrid: { + flexDirection: 'row', + gap: 3, + }, + heatmapCol: { + 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/docs/rn-migration.md b/docs/rn-migration.md index fa267d3..d65d8e5 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -155,7 +155,42 @@ - **Phase 4 到此可以視為完成並 commit**:登入搬遷、雙 tab 共用 progress state、iOS/Android 真機(模擬器)搬遷路徑都驗證過。「反覆登出登入不會重新同步」是刻意的既有設計(跟 web 版一致), 不是待辦事項。 -- [ ] **Phase 5**:剩餘畫面(Notes/QuestionBook、Stats、review/mixed/savedpractice)。 +- **2026-07-13,Phase 5 開工前追加**:使用者反映 apps/mobile 淺色主題太刺眼,已把 + `useColorScheme`(含 `.web.ts` 變體)鎖死回傳 `dark`,不再看裝置系統設定;`Colors.ts` + 的 dark 色票從刺眼的純黑 `#000`/純白 `#fff` 改成柔和的 `#121212`/`#e6e6e6`。過程中 + 發現 `Icon.tsx` 原本用 `react-native` 原生 `Text` 顯示非 emoji 符號(`✕←→›↺`),預設黑字 + 在深色背景幾乎全隱形(真正的彩色 emoji 圖示不受影響),改成從 `components/Themed.tsx` + 匯入 `Text` 讓它跟著主題文字色走。這是暫時性的過渡措施,不是 Phase 5/6 要做的「星際 HUD」 + 視覺(那個之後會直接取代 Colors.ts 這套機制),**之後真的動視覺 phase 時要記得這裡的 + 強制 dark 是暫時的,不要誤以為是最終設計**。已 commit(`98c3923`)並 push 到 `origin/dev`。 + 這個環境沒辦法看實際畫面,使用者尚未回報確認過修好之後的顯示效果。 +- [x] **Phase 5:剩餘畫面**(2026-07-13 實作完成,**尚未經使用者在模擬器/真機驗證**) + - 新增 `apps/mobile/screens/{Notes,QuestionBook,QuestionReview,Stats}.tsx`:邏輯/資料流對照 + `apps/web/src/screens/{Notes,QuestionBook,Stats}.tsx` 與 `apps/web/src/components/QuestionReview.tsx`, + 同樣呼叫 `packages/core` 的 `getWrongQuestions`/`getWrongEntries`/`getSavedQuestions`/`REVIEW_SIZE` + 等既有函式,畫面改用 RN 的 View/Text/Pressable/ScrollView+StyleSheet 重寫。 + - `app/(tabs)/notes.tsx` 取代原本的 `ComingSoon` 佔位畫面,用跟 `app/(tabs)/index.tsx`(Home tab) + 同一種手法——一個 `view` 狀態機(notes/wrongbook/savedbook/review/savedpractice)在單一 tab 內 + 切畫面,`review`/`savedpractice` 直接複用 Phase 3 就有的 `Quiz` 元件(`mode="review"`/ + `mode="mixed"`),跟 Home tab 各自獨立一個狀態機、但共用同一份 `ProgressProvider` context。 + - `app/(tabs)/stats.tsx` 取代 `ComingSoon`,是單一唯讀畫面(不需要 view 狀態機,跟 Profile tab + 同構)。`screens/Stats.tsx` 把 web 版的日期/熱力圖/統計計算邏輯整段搬過來(沒有搬進 + `packages/core`,因為這些函式只有 UI 層在用,不是 web/mobile 共用的資料邏輯),熱力圖用 + RN 的 `ScrollView horizontal` 取代 web 版的 CSS overflow-x scroll。 + - **刻意跟 web 版不同、屬於 MVP 簡化,不是漏做**:熱力圖格子跟迷你長條圖拿掉了 web 版滑鼠 + hover 才有的 `title` tooltip——觸控裝置本來就沒有 hover 這個互動,跟 Phase 3 `CodeBlock`/ + `Icon.tsx` 用 emoji 頂替 SVG 圖示是同一個「先求功能完整,不追求像素級一致視覺」原則。 + - 沒用到的 `components/ComingSoon.tsx`(Phase 2/3 的四個佔位畫面已經全部被真正內容取代) + 已直接刪除,不留 dead code。 + - **這個環境能驗證的都驗證了**:`apps/mobile` `tsc --noEmit` 過、`expo-doctor` 19/20(唯一沒過的 + 仍是 Phase 2 就有、刻意的 metro monorepo 設定,跟這次改動無關)、`npx expo export --platform web` + 成功(1358 modules,`/notes`/`/stats` 兩條新路由都正確輸出)。 + - **完全沒有測過的部分(使用者要自己在真機/模擬器驗證才能定案)**:Notes tab 四張卡片/清單的 + 觸控互動是否正常(尤其卡片本身可點開清單、卡片內又有一個獨立的「開始複習/開始練習」按鈕, + 這種巢狀 `Pressable` 在 RN 的觸控responder 機制跟 web 的事件冒泡不是同一套,行為需要真機確認); + Stats tab 熱力圖橫向捲動手感、迷你長條圖數字是否對得上;`review`/`savedpractice` 兩個模式 + 重用 `Quiz` 元件后結算/離開流程是否正確;深色主題下這幾個新畫面的顏色(尤其錯題卡的紅色調、 + 收藏卡的藍色調)是否跟其他畫面一致、看起來舒服。 - [ ] **Phase 6**:頭像拖曳/縮放/改名(最高複雜度的 native gesture,刻意排最後)。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 From 18de9d28969a2fcc86c1daef56a1ecbb73f92919 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:20:53 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E9=8C=AF=E9=A1=8C=E6=9C=AC=E6=94=B9?= =?UTF-8?q?=E6=88=90=E7=AD=94=E5=B0=8D=E4=B8=80=E6=AC=A1=E5=B0=B1=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82=20Leitne?= =?UTF-8?q?r=20=E7=9B=92=E5=88=B6=E7=9A=84=E4=B8=89=E6=AC=A1=E9=80=A3?= =?UTF-8?q?=E7=BA=8C=E7=AD=94=E5=B0=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/core 的 applyAnswer 是 web/mobile 共用邏輯,直接改成正確就刪除錯題紀錄。 apps/web 登入時的伺服器端寫入(api/progress/answer/route.ts)是另外手刻同一套規則的 Prisma 交易,沒有共用 applyAnswer,這次一起同步改掉。GRADUATE_BOX 常數與 UI 上的 「熟練度 X/3」顯示已跟著移除(移出時機改成單次答對後,這個數字永遠停在 1,留著只會 誤導);WrongEntryMeta.box 型別欄位與 Prisma 資料庫欄位刻意保留不動,避免多一次 schema migration,一律寫 1、不再遞增。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/screens/QuestionReview.tsx | 5 +---- apps/web/src/app/api/progress/answer/route.ts | 14 +++---------- apps/web/src/components/QuestionReview.tsx | 5 +---- apps/web/src/hooks/useProgress.ts | 6 +++--- docs/rn-migration.md | 20 +++++++++++++------ packages/core/src/progressCalc.ts | 13 +++--------- 6 files changed, 25 insertions(+), 38 deletions(-) diff --git a/apps/mobile/screens/QuestionReview.tsx b/apps/mobile/screens/QuestionReview.tsx index f7e85e0..575359d 100644 --- a/apps/mobile/screens/QuestionReview.tsx +++ b/apps/mobile/screens/QuestionReview.tsx @@ -3,7 +3,7 @@ import { Linking, Pressable, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; import CodeBlock from '@/components/CodeBlock'; -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; @@ -34,9 +34,6 @@ export default function QuestionReview({ question, saved, onToggleSave, meta }: {meta && ( 答錯 {meta.count} 次 - - 熟練度 {meta.box}/{GRADUATE_BOX} - {meta.lastWrong && 最近答錯:{meta.lastWrong}} )} 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/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/docs/rn-migration.md b/docs/rn-migration.md index d65d8e5..c7e4556 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -185,12 +185,20 @@ - **這個環境能驗證的都驗證了**:`apps/mobile` `tsc --noEmit` 過、`expo-doctor` 19/20(唯一沒過的 仍是 Phase 2 就有、刻意的 metro monorepo 設定,跟這次改動無關)、`npx expo export --platform web` 成功(1358 modules,`/notes`/`/stats` 兩條新路由都正確輸出)。 - - **完全沒有測過的部分(使用者要自己在真機/模擬器驗證才能定案)**:Notes tab 四張卡片/清單的 - 觸控互動是否正常(尤其卡片本身可點開清單、卡片內又有一個獨立的「開始複習/開始練習」按鈕, - 這種巢狀 `Pressable` 在 RN 的觸控responder 機制跟 web 的事件冒泡不是同一套,行為需要真機確認); - Stats tab 熱力圖橫向捲動手感、迷你長條圖數字是否對得上;`review`/`savedpractice` 兩個模式 - 重用 `Quiz` 元件后結算/離開流程是否正確;深色主題下這幾個新畫面的顏色(尤其錯題卡的紅色調、 - 收藏卡的藍色調)是否跟其他畫面一致、看起來舒服。 + - **2026-07-13,使用者在真機/模擬器測過(Notes tab 巢狀按鈕觸控、Stats tab 熱力圖橫向捲動、 + review/savedpractice 結算流程、深色主題配色)四項都 OK**。 + - **2026-07-13 追加,使用者測試後要求改變錯題本規則**:原本是 Leitner 盒制(答對要升滿 + `GRADUATE_BOX`=3 盒才移出錯題本),改成「答對一次就直接移出錯題本」。這是 `packages/core` + 的共用邏輯(`progressCalc.ts` 的 `applyAnswer`),同時也要改 `apps/web/src/app/api/progress/ + answer/route.ts`(登入時的伺服器端寫入是另外手刻同一套規則的 Prisma 交易,沒有共用 + `applyAnswer`,這次一起改成答對就 `delete`,不再比較 `box+1 > GRADUATE_BOX`)。`GRADUATE_BOX` + 常數跟 `熟練度 X/3` 的 UI 顯示(`QuestionReview.tsx`,web/mobile 各一份)已經完全移除,因為 + 移出時機改成單次答對後,這個欄位永遠停在 1,留著顯示只會誤導。**`WrongEntryMeta.box` 欄位/ + Prisma `WrongEntry.box` 資料庫欄位本身刻意保留沒動**(一律寫 1,不再遞增)——避免多動一次 + schema migration,這欄位現在只是歷史包袱,之後如果要徹底清乾淨可以再考慮拿掉。這個環境驗證過 + `apps/web`/`apps/mobile` 的 `tsc --noEmit`、`web` 的 `lint`/`build`、`mobile` 的 + `expo export --platform web`,都過;**新規則本身的效果(答對後錯題確實從清單消失)使用者 + 還沒回報驗證過**。 - [ ] **Phase 6**:頭像拖曳/縮放/改名(最高複雜度的 native gesture,刻意排最後)。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 diff --git a/packages/core/src/progressCalc.ts b/packages/core/src/progressCalc.ts index e3d97ab..e0c5c3d 100644 --- a/packages/core/src/progressCalc.ts +++ b/packages/core/src/progressCalc.ts @@ -1,8 +1,5 @@ import type { DailyStat, Progress, Streak, WrongEntryMeta } from './types' -// Leitner 盒制:答對升級、答錯重置回 1,box 超過畢業盒才移出錯題本 -export const GRADUATE_BOX = 3 - const toDateStr = (d: Date): string => d.toLocaleDateString('en-CA') // 本地時區的 YYYY-MM-DD export const todayStr = (): string => toDateStr(new Date()) @@ -43,8 +40,8 @@ export const bumpStreak = (streak: Streak): Streak => { } } -// 答題後更新 Leitner 錯題本+每日/分科作答統計,web/mobile 的 useProgress 共用同一份規則: -// 答錯→記入或重置回第 1 盒;答對→有錯題紀錄才升一盒,超過畢業盒就移出錯題本 +// 答題後更新錯題本+每日/分科作答統計,web/mobile 的 useProgress 共用同一份規則: +// 答錯→記入或重置錯題紀錄;答對→只要這題還在錯題本裡就直接移出(答對一次就畢業) export const applyAnswer = ( progress: Progress, questionId: string, @@ -54,11 +51,7 @@ export const applyAnswer = ( const wrongIds = { ...progress.wrongIds } const entry = wrongIds[questionId] if (correct) { - if (entry) { - const box = entry.box + 1 - if (box > GRADUATE_BOX) delete wrongIds[questionId] - else wrongIds[questionId] = { ...entry, box } - } + if (entry) delete wrongIds[questionId] } else { const nextEntry: WrongEntryMeta = { count: (entry?.count ?? 0) + 1, lastWrong: todayStr(), box: 1 } wrongIds[questionId] = nextEntry From 9b6e1ed0c7a58953b89413a7ba07e2e9104271a5 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:31:19 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20rn-migration.md?= =?UTF-8?q?=EF=BC=9A=E9=8C=AF=E9=A1=8C=E6=9C=AC=E6=94=B9=E5=8B=95=E5=B7=B2?= =?UTF-8?q?=E7=B6=93=E4=BD=BF=E7=94=A8=E8=80=85=E9=A9=97=E8=AD=89=E9=80=9A?= =?UTF-8?q?=E9=81=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- docs/rn-migration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rn-migration.md b/docs/rn-migration.md index c7e4556..12393ba 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -197,8 +197,8 @@ Prisma `WrongEntry.box` 資料庫欄位本身刻意保留沒動**(一律寫 1,不再遞增)——避免多動一次 schema migration,這欄位現在只是歷史包袱,之後如果要徹底清乾淨可以再考慮拿掉。這個環境驗證過 `apps/web`/`apps/mobile` 的 `tsc --noEmit`、`web` 的 `lint`/`build`、`mobile` 的 - `expo export --platform web`,都過;**新規則本身的效果(答對後錯題確實從清單消失)使用者 - 還沒回報驗證過**。 + `expo export --platform web`,都過;**2026-07-13 使用者已在真機/模擬器測過,答對後錯題確實 + 從清單消失,符合預期**。已 commit(`18de9d2`)並 push 到 `origin/dev`。 - [ ] **Phase 6**:頭像拖曳/縮放/改名(最高複雜度的 native gesture,刻意排最後)。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 From e218fd63f8d13420b134d2d00627bf6f6a31b552 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:39:38 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20apps/mobile=20Profile?= =?UTF-8?q?=20=E9=A0=AD=E5=83=8F=E6=8B=96=E6=9B=B3/=E7=B8=AE=E6=94=BE/?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E8=88=87=E5=90=89=E7=A5=A5=E7=89=A9=E6=88=90?= =?UTF-8?q?=E9=95=B7=E5=8F=B2=EF=BC=88Phase=206=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 apps/web/src/lib/stages.ts 的吉祥物成長階段資料搬進 packages/core(web/mobile 共用, Phase 1 漏搬的部分)。新增 apps/mobile 的 Mascot/GrowthHistory/AccountHeader 三個元件, AccountHeader 對照 web 版的頭像裁切換算公式整段照搬,互動層換成 react-native-gesture-handler 的 PanGestureHandler(拖曳)+ @react-native-community/slider (縮放)+ expo-image-picker(選照片,URI 轉 Blob 交給 Clerk setProfileImage)。 app/_layout.tsx 加上 GestureHandlerRootView,app.json 補上 expo-image-picker 的 plugin 設定。profile.tsx 補齊帳號設定(登出/刪除帳號)與成長史展開/收合。 這是全計畫風險最高、完全沒有真機驗證過的一個 phase,需要重新原生建置才能測。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/app.json | 8 +- apps/mobile/app/(tabs)/profile.tsx | 271 ++++++++++-- apps/mobile/app/_layout.tsx | 17 +- apps/mobile/components/AccountHeader.tsx | 393 ++++++++++++++++++ apps/mobile/components/GrowthHistory.tsx | 74 ++++ apps/mobile/components/Mascot.tsx | 73 ++++ apps/web/src/components/GrowthHistory.tsx | 2 +- apps/web/src/components/Mascot.tsx | 2 +- apps/web/src/screens/Profile.tsx | 3 +- apps/web/src/screens/Quiz.tsx | 3 +- docs/rn-migration.md | 49 ++- packages/core/src/index.ts | 1 + .../src/lib => packages/core/src}/stages.ts | 0 13 files changed, 844 insertions(+), 52 deletions(-) create mode 100644 apps/mobile/components/AccountHeader.tsx create mode 100644 apps/mobile/components/GrowthHistory.tsx create mode 100644 apps/mobile/components/Mascot.tsx rename {apps/web/src/lib => packages/core/src}/stages.ts (100%) 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)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 824db70..4240b7d 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, 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 } 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,104 @@ 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 = nextStage + ? Math.min(100, Math.round(((progress.xp - stage.min) / (nextStage.min - stage.min)) * 100)) + : 100; + return ( - - - {user?.firstName ?? user?.primaryEmailAddress?.emailAddress ?? '已登入'} - - - {!hydrated && } - - {hydrated && ( - - - - - - - )} + + {!user ? ( + + ) : ( + <> + + - signOut()}> - 登出 - - + + + + + {stage.name} + + {nextStage ? `${progress.xp} / ${nextStage.min} XP` : `${progress.xp} XP・已達最終型態`} + + + + + + + setShowGrowth((v) => !v)} hitSlop={8}> + {showGrowth ? '收起 ‹' : '成長史 ›'} + + + + + {showGrowth && ( + + + + )} + + 學習統計 + {!hydrated ? ( + + ) : ( + + + + + + + )} + + 帳號設定 + + signOut()}> + + + 登出 + + + + + + + + {deleting ? '刪除中…' : '刪除帳號'} + + + + + + + )} + ); } @@ -106,11 +202,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 +234,96 @@ 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, + marginTop: 16, + paddingTop: 14, + borderTopWidth: 1, + borderTopColor: '#88889918', + borderStyle: 'dashed', + }, + 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', + }, + growthPanel: { + borderRadius: 12, + padding: 12, + backgroundColor: '#88889908', + }, + 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 +331,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/_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..b8b2f92 --- /dev/null +++ b/apps/mobile/components/AccountHeader.tsx @@ -0,0 +1,393 @@ +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), + }; +}; + +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 response = await fetch(result.assets[0].uri); + const blob = await response.blob(); + 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}> + + + + )} + USER.{user.id.slice(-8).toUpperCase()} + + + + {isRepositioning && ( + + + + setPos((prev) => ({ ...prev, scale: clamp(v, MIN_SCALE, MAX_SCALE) }))} + minimumTrackTintColor="#2e78b7" + /> + + + + + 完成 + + + + 取消 + + + 拖曳照片可移動位置,拉桿可縮放,完成後按「完成」儲存。 + + )} + + ); +} + +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: { + borderWidth: 2, + borderStyle: 'dashed', + 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', + borderWidth: 1, + borderColor: '#88889940', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + userId: { + fontSize: 11, + opacity: 0.5, + }, + 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/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/Mascot.tsx b/apps/mobile/components/Mascot.tsx new file mode 100644 index 0000000..18be1fe --- /dev/null +++ b/apps/mobile/components/Mascot.tsx @@ -0,0 +1,73 @@ +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Themed'; +import { getNextStage, getStage } 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 = next + ? Math.min(100, Math.round(((xp - stage.min) / (next.min - stage.min)) * 100)) + : 100; + + 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/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..b173f22 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 } from '@easylearn/core' interface MascotProps { xp: number 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..ba9433b 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 diff --git a/docs/rn-migration.md b/docs/rn-migration.md index 12393ba..0c7d524 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -199,7 +199,54 @@ `apps/web`/`apps/mobile` 的 `tsc --noEmit`、`web` 的 `lint`/`build`、`mobile` 的 `expo export --platform web`,都過;**2026-07-13 使用者已在真機/模擬器測過,答對後錯題確實 從清單消失,符合預期**。已 commit(`18de9d2`)並 push 到 `origin/dev`。 -- [ ] **Phase 6**:頭像拖曳/縮放/改名(最高複雜度的 native gesture,刻意排最後)。 +- [x] **Phase 6:頭像拖曳/縮放/改名**(2026-07-13 實作完成,**完全沒有真機/模擬器驗證過, + 風險最高,需要先重新原生建置才能測**) + - `packages/core/src/stages.ts`(新增):把 `apps/web/src/lib/stages.ts` 的吉祥物成長階段資料 + (`STAGES`/`getStage`/`getNextStage`)搬進共用層——這是 Phase 1 就該搬但當時漏掉的純資料/ + 函式,這次 mobile 的 `Mascot`/`GrowthHistory` 也要用到才發現。`apps/web` 的 + `lib/stages.ts` 已刪除,`Profile.tsx`/`Quiz.tsx`/`Mascot.tsx`/`GrowthHistory.tsx` + 改成從 `@easylearn/core` 匯入,行為完全沒變,純粹是搬家。 + - 新增 `apps/mobile/components/{Mascot,GrowthHistory}.tsx`:對照 web 同名元件,靜態顯示, + 沒有搬 web 版 `mood="happy"` 的彈跳動畫(跟 Phase 3 的簡化原則一致)。 + - 新增 `apps/mobile/components/AccountHeader.tsx`(這個 phase 最複雜的部分):對照 web 版 + `AccountHeader.tsx` 的頭像拖曳/縮放/改名,換算公式(`getOverflow`/裁切位置計算)整段照搬, + 只有互動層换成 RN 對應方案: + - 拖曳:web 版用滑鼠 `onPointerDown/Move/Up` 手算 dx/dy;mobile 改用 + `react-native-gesture-handler` 的 `PanGestureHandler`(沿用 v1 的 `onGestureEvent`/ + `onHandlerStateChange` API,不是新版 `Gesture.Pan()`+worklet,减少複雜度), + `translationX/Y` 本來就是相對手勢起點的累計位移,跟 web 版邏輯概念一致,公式沒改。 + `react-native-gesture-handler` 需要整個 app 包一層 `GestureHandlerRootView`, + 已經加到 `app/_layout.tsx` 最外層。 + - 縮放:`` 換成 `@react-native-community/slider` 的 ``。 + - 選照片:`expo-image-picker` 的 `launchImageLibraryAsync`,選到的本機 `file://` URI + 透過 `fetch(uri).then(r => r.blob())` 轉成 `Blob` 再交給 Clerk 的 + `user.setProfileImage({ file: blob })`——RN 沒有瀏覽器的 `File`/``, + 這個 URI→Blob 轉換是 RN 上傳圖片給任何 API(含 Clerk)的標準作法,但**這條路徑完全沒有 + 實機測過,`fetch().blob()` 在 Expo 的 polyfill 下是否真的能把 Clerk 需要的圖片資料正確 + 送出,是這個 phase 最大的不確定性**。 + - 已在 `app.json` 的 `plugins` 加上 `expo-image-picker` 的設定(帶中文相簿權限說明文字), + 這是 Phase 2 只裝套件、沒寫 config plugin 設定的收尾。 + - `app/(tabs)/profile.tsx` 大幅擴充:原本只有登入後顯示 4 個統計數字+登出按鈕的極簡版, + 這次補齊 web 版 `Profile.tsx` 其餘一直沒搬的部分——`AccountHeader`(頭像/改名)、 + `Mascot`(size="sm")+ XP 進度條、「成長史」展開/收合、`GrowthHistory`、帳號設定區塊的 + 登出/刪除帳號。刪除帳號用 `Alert.alert` 的 destructive 按鈕做確認(RN 没有 + `window.confirm`),呼叫既有的 `DELETE /api/account`(`lib/api.ts` 的 `request`,這支 + endpoint 在 Phase 4 就已經存在於 apps/web,這次是 mobile 第一次呼叫它)。 + - **這個環境能驗證的都驗證了**:`packages/core`/`apps/web`/`apps/mobile` 三個 + `tsc --noEmit` 過、`apps/web` 的 `lint`/`build` 過、`apps/mobile` 的 + `expo export --platform web` 成功(1516 modules,`/profile` 路由正確輸出)、 + `expo-doctor` 19/20(唯一沒過的仍是 Phase 2 就有、刻意的 metro monorepo 設定)。 + - **完全沒有測過、風險最高的部分(使用者要自己在真機/模擬器驗證才能定案)**: + 1. `app.json` 這次改了 `plugins`(新增 `expo-image-picker` 設定)+ `app/_layout.tsx` + 加了 `GestureHandlerRootView`,**必須重新 `npx expo run:ios`/`run:android` 完整原生 + 建置**(不是新裝 native module,但 app.json 的 config plugin 變更同樣需要重新產生 + 原生專案設定),光重啟 `expo start` 不會生效,這點跟 Phase 2/4 提醒過的規則一致。 + 2. 拖曳頭像的手感、縮放拉桿是否跟拖曳同步、放開後位置是否正確换算——這組數學公式是照搬 + web 版沒改邏輯,但 web 是滑鼠事件、mobile 是觸控手勢,實際手感需要真機才能判斷。 + 3. 選照片→上傳→Clerk `setProfileImage` 整條路徑能不能成功(見上面 URI→Blob 轉換的 + 不確定性),包含相簿權限彈窗文字是否正確顯示。 + 4. 改名存檔、成長史展開/收合、刪除帳號的確認對話框與實際刪除流程。 +- [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 ## 驗證紀律(跨 phase 都適用) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d82520b..6701010 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from './types' export * from './progressCalc' export * from './quiz' +export * from './stages' export * from './data/chapters' export * from './data/typeMeta' diff --git a/apps/web/src/lib/stages.ts b/packages/core/src/stages.ts similarity index 100% rename from apps/web/src/lib/stages.ts rename to packages/core/src/stages.ts From 78cd8a7866a5071fa4fdb33b3d479e222d4c9f27 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 12:59:03 +0800 Subject: [PATCH 6/8] =?UTF-8?q?Profile=20=E7=95=AB=E9=9D=A2=20UI=20?= =?UTF-8?q?=E8=AA=BF=E6=95=B4=EF=BC=9A=E6=8B=BF=E6=8E=89=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=80=85=20ID=E3=80=81=E6=88=90=E9=95=B7=E5=8F=B2=E7=8D=A8?= =?UTF-8?q?=E7=AB=8B=E5=8D=A1=E7=89=87=E3=80=81=E5=9C=96=E7=A4=BA=E6=8F=9B?= =?UTF-8?q?=E6=88=90=20lucide=20=E7=B7=9A=E5=9E=8B=E9=A2=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回應真機測試後的三項設計回饋:AccountHeader 移除 USER.XXXXXXXX 顯示;profile.tsx 把成長史(吉祥物/XP/展開清單)拆成跟「個人資料」各自獨立的卡片,不再擠在同一張卡片裡; Icon.tsx 從 emoji 換成 lucide-react-native(跟 apps/web Icons.tsx 同源的線型圖示), 用逐一深層匯入(lucide-react-native/icons/xxx)避免 barrel import 讓 Metro 把全部 3000+ 個圖示一起打包進 bundle。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/app/(tabs)/profile.tsx | 27 +++--- apps/mobile/components/AccountHeader.tsx | 5 - apps/mobile/components/Icon.tsx | 113 +++++++++++++++-------- apps/mobile/package.json | 1 + docs/rn-migration.md | 20 +++- yarn.lock | 5 + 6 files changed, 114 insertions(+), 57 deletions(-) diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 4240b7d..4127d3b 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -122,9 +122,13 @@ export default function ProfileScreen() { ) : ( <> + 個人資料 + + 成長史 + @@ -139,17 +143,16 @@ export default function ProfileScreen() { setShowGrowth((v) => !v)} hitSlop={8}> - {showGrowth ? '收起 ‹' : '成長史 ›'} + {showGrowth ? '收起 ‹' : '展開 ›'} + {showGrowth && ( + + + + )} - {showGrowth && ( - - - - )} - 學習統計 {!hydrated ? ( @@ -177,12 +180,12 @@ export default function ProfileScreen() { disabled={deleting} > - + {deleting ? '刪除中…' : '刪除帳號'} - + @@ -258,11 +261,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 14, - marginTop: 16, - paddingTop: 14, - borderTopWidth: 1, - borderTopColor: '#88889918', - borderStyle: 'dashed', }, heroXpInfo: { flex: 1, @@ -301,6 +299,7 @@ const styles = StyleSheet.create({ growthPanel: { borderRadius: 12, padding: 12, + marginTop: 14, backgroundColor: '#88889908', }, sectionTitle: { diff --git a/apps/mobile/components/AccountHeader.tsx b/apps/mobile/components/AccountHeader.tsx index b8b2f92..8e9142a 100644 --- a/apps/mobile/components/AccountHeader.tsx +++ b/apps/mobile/components/AccountHeader.tsx @@ -252,7 +252,6 @@ export default function AccountHeader({ user }: AccountHeaderProps) { )} - USER.{user.id.slice(-8).toUpperCase()} @@ -358,10 +357,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4, }, - userId: { - fontSize: 11, - opacity: 0.5, - }, repositionPanel: { gap: 10, }, diff --git a/apps/mobile/components/Icon.tsx b/apps/mobile/components/Icon.tsx index 9dd975a..554badd 100644 --- a/apps/mobile/components/Icon.tsx +++ b/apps/mobile/components/Icon.tsx @@ -1,46 +1,85 @@ -import { Text } from '@/components/Themed'; +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/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/docs/rn-migration.md b/docs/rn-migration.md index 0c7d524..8848baa 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -246,7 +246,25 @@ 3. 選照片→上傳→Clerk `setProfileImage` 整條路徑能不能成功(見上面 URI→Blob 轉換的 不確定性),包含相簿權限彈窗文字是否正確顯示。 4. 改名存檔、成長史展開/收合、刪除帳號的確認對話框與實際刪除流程。 -- [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 + - **2026-07-13,使用者原生重建後實機測過,回報三項 UI 調整**(不是 bug,是設計回饋): + 1. 個人資料不需要顯示 `USER.XXXXXXXX` 這行——`AccountHeader.tsx` 已移除。 + 2. 成長史(吉祥物/XP/展開清單)不該跟大頭貼/改名擠在同一張卡片裡——`profile.tsx` + 拆成「個人資料」「成長史」兩張各自獨立的卡片區塊(各自有自己的 section title), + 原本卡片內部的 dashed 分隔線樣式跟著拿掉。 + 3. 圖示要換成跟 tab bar 一樣的線型簡約風格,不要用 emoji——`Icon.tsx` 整個換掉,改用 + `lucide-react-native`(新增依賴,peer dep 是已裝好的 `react-native-svg`,純 JS, + **不需要重新原生建置**,Metro/`expo start` 重新整理就會生效)。這個套件就是 + apps/web `Icons.tsx` 那組線型圖示(lucide.dev)的官方 RN 版本,圖示名稱/路徑跟 web + 版同源,只有 3 個圖示在新版 lucide 改了名字(`check-circle`→`CircleCheck`、 + `home`→`House`、`bar-chart`→`ChartColumn`),對照 SVG path 資料確認過是同一個圖示。 + **踩過的坑**:一開始用根套件的具名匯入(`import { ArrowLeft } from 'lucide-react-native'`), + `expo export --platform web` 一測發現 bundle 從 3.1MB 漲到 5.3MB、module 數從 1516 + 跳到 3296——Metro 對這種全量 barrel re-export 的 tree-shaking 不夠乾淨,即使只用 29 + 個圖示還是把全部 3000+ 個圖示打包進去。改成 `lucide-react-native/icons/xxx` 逐一深層 + 匯入後,bundle 回到 3.5MB(只比沒有圖示庫的原始 baseline 多 ~0.3MB,符合預期)。 + - 這輪調整驗證過:`tsc --noEmit`、`expo export --platform web`(module 數回到 1397, + `/profile` 正確輸出)、`expo-doctor` 19/20(同樣跟這次無關的既有問題)。 + **新版畫面(拆卡片+線型圖示)使用者還沒回報看過**,下次要接續先請使用者確認外觀。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 ## 驗證紀律(跨 phase 都適用) diff --git a/yarn.lock b/yarn.lock index 739ebf8..92eebe1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4806,6 +4806,11 @@ lru.min@^1.0.0, lru.min@^1.1.0: resolved "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== +lucide-react-native@^1.24.0: + version "1.24.0" + resolved "https://registry.npmmirror.com/lucide-react-native/-/lucide-react-native-1.24.0.tgz#165498e47aff0b570a8655333f4743ebf9991703" + integrity sha512-4nfHfK75Azty3bilbuW1tzgi0kqG4eHFIex7x2hP2MMNM4CuRDo9+4PEtKKoImXiFSMc7J0BeOixZlz6Ucm9VA== + makeerror@1.0.12: version "1.0.12" resolved "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" From 99972a56f1178d67ef5006a35da7917e8ee0004f Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 13:23:54 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BF=AE=E5=BE=A9=E9=A0=AD=E5=83=8F?= =?UTF-8?q?=E4=B8=8A=E5=82=B3=20Blob=20=E4=BE=8B=E5=A4=96=EF=BC=8CProfile?= =?UTF-8?q?=20=E9=A0=81=E9=9D=A2=E6=A8=A3=E5=BC=8F=E8=88=87=E4=BA=92?= =?UTF-8?q?=E5=8B=95=E5=BE=AE=E8=AA=BF=EF=BC=88Phase=206=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E9=A9=97=E8=AD=89=E9=80=9A=E9=81=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修復 AccountHeader.tsx 選照片上傳丟出的 RN Blob 例外:fetch(uri).blob() 沒辦法從 ArrayBuffer 建立 RN 認得的 Blob,改用 XMLHttpRequest 讀取本機圖片 URI。同時處理三項 真機測試發現的樣式問題:縮放拉桿在深色主題下配色補齊(原本吃套件預設淺色)、頭像編輯框的 dashed border 改成純色實線(RN 對圓形疊虛線框是已知平台限制)、改名輸入框補上文字顏色 (TextInput 沒有走 Themed 自動套色,預設黑字在深色底看不見)。 另外三項介面回饋:拿掉縮放拉桿旁的放大鏡圖示;成長史從卡片內展開改成 Modal 彈窗顯示; 移除輸入框顏色問題後的最終確認。 使用者已在 iOS/Android 模擬器完整測試通過(含用 adb push 測試圖片驗證 Android 上傳路徑)。 Co-Authored-By: Claude Sonnet 5 --- apps/mobile/app/(tabs)/profile.tsx | 53 ++++++++++++++++++------ apps/mobile/components/AccountHeader.tsx | 27 +++++++++--- docs/rn-migration.md | 34 ++++++++++++++- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 4127d3b..d65ab55 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ActivityIndicator, Alert, Pressable, ScrollView, 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'; @@ -142,17 +142,28 @@ export default function ProfileScreen() { - setShowGrowth((v) => !v)} hitSlop={8}> - {showGrowth ? '收起 ‹' : '展開 ›'} + setShowGrowth(true)} hitSlop={8}> + 查看全部 › - {showGrowth && ( - - - - )} + setShowGrowth(false)}> + + + + 成長史 + setShowGrowth(false)} hitSlop={8}> + + + + + + + + + + 學習統計 {!hydrated ? ( @@ -296,11 +307,27 @@ const styles = StyleSheet.create({ fontWeight: '700', color: '#2e78b7', }, - growthPanel: { - borderRadius: 12, - padding: 12, - marginTop: 14, - backgroundColor: '#88889908', + 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, diff --git a/apps/mobile/components/AccountHeader.tsx b/apps/mobile/components/AccountHeader.tsx index 8e9142a..1a23f62 100644 --- a/apps/mobile/components/AccountHeader.tsx +++ b/apps/mobile/components/AccountHeader.tsx @@ -52,6 +52,20 @@ const getOverflow = (natural: NaturalSize, scale: number) => { }; }; +// 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; } @@ -104,8 +118,7 @@ export default function AccountHeader({ user }: AccountHeaderProps) { setPosBeforeEdit(pos); setUploading(true); try { - const response = await fetch(result.assets[0].uri); - const blob = await response.blob(); + const blob = await uriToBlob(result.assets[0].uri); await user.setProfileImage({ file: blob }); // 立刻把新照片的預設裁切位置存進 Clerk metadata:就算使用者這次調整完直接按「取消」, // 下次讀到的也是這張新照片的預設值,不會沿用上一張照片留下的舊裁切座標 @@ -258,7 +271,6 @@ export default function AccountHeader({ user }: AccountHeaderProps) { {isRepositioning && ( - setPos((prev) => ({ ...prev, scale: clamp(v, MIN_SCALE, MAX_SCALE) }))} minimumTrackTintColor="#2e78b7" + maximumTrackTintColor="#88889940" + thumbTintColor="#ffffff" /> @@ -310,8 +324,10 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, avatarRepositioning: { - borderWidth: 2, - borderStyle: 'dashed', + // 原本用 borderStyle:'dashed' 表示「正在調整」,但 RN 的 dashed border 疊在圓形 + // (borderRadius 50%)上是已知的平台限制,繞著曲線的虛線間距算不出來,實機上只會 + // 斷斷續續露出一小段,不是我們的樣式寫錯——改用純色框,一樣能清楚表示進入編輯模式。 + borderWidth: 3, borderColor: '#ffb454', }, avatarInitial: { @@ -351,6 +367,7 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 14, fontWeight: '600', + color: '#e6e6e6', borderWidth: 1, borderColor: '#88889940', borderRadius: 8, diff --git a/docs/rn-migration.md b/docs/rn-migration.md index 8848baa..710d6da 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -264,7 +264,39 @@ 匯入後,bundle 回到 3.5MB(只比沒有圖示庫的原始 baseline 多 ~0.3MB,符合預期)。 - 這輪調整驗證過:`tsc --noEmit`、`expo export --platform web`(module 數回到 1397, `/profile` 正確輸出)、`expo-doctor` 19/20(同樣跟這次無關的既有問題)。 - **新版畫面(拆卡片+線型圖示)使用者還沒回報看過**,下次要接續先請使用者確認外觀。 + - **2026-07-13,使用者實機測試上傳頭像,抓到一個真的 bug+兩個樣式問題**: + 1. **Bug:`AccountHeader.tsx` 選照片上傳直接丟例外**—— + `Error: Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported`。 + 根因是 RN 的 `Blob` 實作沒辦法從 `fetch()` 內部轉出的 `ArrayBuffer` 建立 Blob,原本寫的 + `fetch(uri).then(r => r.blob())` 在 RN 上傳本機圖片這條路徑上行不通(web 版沒這個問題, + 瀏覽器的 `fetch().blob()` 沒有這個限制)。**修法**:改用 `XMLHttpRequest` + 以 `responseType: 'blob'` 直接讀取本機 URI(RN 社群/Firebase Storage RN 文件都用這個 + workaround,是原生模組認得的 Blob 來源),新增 `uriToBlob()` helper 取代原本的 + fetch 寫法。 + 2. **樣式:縮放拉桿在深色主題下整條變成一坨白色**——原本只設定了 + `minimumTrackTintColor`,`maximumTrackTintColor`/`thumbTintColor` 留預設(淺灰/白, + 設計給淺色背景用),在強制深色主題下滑軌+拉桿糊成一片看不出层次。補上深色主題對應的 + `maximumTrackTintColor="#88889940"`。 + 3. **樣式:頭像編輯模式的外框只在最上緣露出一小段橘色,不是完整虛線圓**—— + `borderStyle: 'dashed'` 疊在圓形(`borderRadius: 50%`)上是 React Native 已知的平台 + 限制,虛線間距沒辦法正確沿著曲線計算,不是我們的樣式參數寫錯。**修法**:改用純色實線框 + (拿掉 `borderStyle: 'dashed'`),一樣能表達「進入編輯模式」,不會有這個算不出來的問題。 + - **2026-07-13 追加,使用者三項介面回饋**(不是 bug,是設計調整): + 1. 縮放拉桿左側的放大鏡圖示拿掉。 + 2. 成長史從「卡片內展開/收合清單」改成點「查看全部 ›」用 RN `Modal`(`animationType="slide"`, + 從底部滑入)彈出完整清單,右上角 X 關閉——不再佔用個人資料頁的常駐版面。 + 3. 改名輸入框的文字是黑色看不清楚——`TextInput` 不像 `Text`/`View` 有走 `components/Themed.tsx` + 自動套主題色,是 RN 原生元件,沒特別設定就吃系統預設黑字,在深色底自然隱形。補上 + `color: '#e6e6e6'`(跟其他文字元件一致的淺色)。順手確認過整個 apps/mobile 只有這一處 + 用到 `TextInput`,沒有其他地方會有同樣問題。 + - **Android 模擬器沒有內建相簿圖片可測試上傳**:模擬器預設空的,這次直接用 `adb push` 把 + `~/桌布/` 底下三張圖推進 `/sdcard/Pictures/`,再用 + `adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file://...` + 觸發媒體掃描讓系統相簿索引到,讓使用者能在 Android 上實測選照片上傳這條路徑 + (純測試用的暫時檔案,不是專案的一部分,不影響 repo)。 + - **2026-07-13,使用者確認「功能測試 OK」**——上傳頭像(含 Android,用上面塞進去的測試圖)、 + 拖曳/縮放、改名、放大鏡移除、成長史彈窗、輸入框文字顏色,全部驗證過沒問題。 + **Phase 6 到此真正完成**,已 commit 並 push 到 `origin/dev`。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 ## 驗證紀律(跨 phase 都適用) From 0ff79a831feab2dbb6393912836aebb4dcdfc236 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Mon, 13 Jul 2026 13:52:55 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=8C=AF=E9=A1=8C?= =?UTF-8?q?=E9=87=8D=E7=B7=B4=E6=96=87=E6=A1=88=E3=80=81=E5=A7=93=E5=90=8D?= =?UTF-8?q?=E8=BC=B8=E5=85=A5=E9=A9=97=E8=AD=89=E3=80=81XP=20=E9=80=B2?= =?UTF-8?q?=E5=BA=A6=E9=87=8D=E8=A4=87=E8=A8=88=E7=AE=97=E8=88=87=20mobile?= =?UTF-8?q?=20=E7=86=B1=E5=8A=9B=E5=9C=96=E5=B0=8D=E9=BD=8A=E5=95=8F?= =?UTF-8?q?=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quiz 錯題重練模式的 banner/結算提示文字改為描述「答對一次即從錯題本移除」, 修正舊版 Leitner 盒制熟練度用語與實際邏輯(applyAnswer)不符的問題 - AccountHeader 姓名確認按鈕在輸入全空白時停用,避免靜默失敗 - 新增 packages/core 的 getStageProgress(),收斂 profile.tsx/web 與 mobile Mascot.tsx 三處重複的 XP 進度百分比算式 - 修正 mobile 學習數據頁熱力圖月份標籤欄寬(16px)與格子欄寬(11px)不一致 導致視覺對不齊、看起來像沒有算到最新日期的問題,補上分科成效細分頁的提示文字 --- apps/mobile/app/(tabs)/profile.tsx | 6 ++--- apps/mobile/components/AccountHeader.tsx | 2 +- apps/mobile/components/Mascot.tsx | 6 ++--- apps/mobile/screens/Quiz.tsx | 4 +-- apps/mobile/screens/Stats.tsx | 31 ++++++++++++++++++++---- apps/web/src/components/Mascot.tsx | 6 ++--- apps/web/src/index.css | 7 ++++++ apps/web/src/screens/Quiz.tsx | 4 +-- apps/web/src/screens/Stats.tsx | 3 +++ packages/core/src/stages.ts | 6 +++++ 10 files changed, 53 insertions(+), 22 deletions(-) diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index d65ab55..41a1fef 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -10,7 +10,7 @@ import Mascot from '@/components/Mascot'; import GrowthHistory from '@/components/GrowthHistory'; import { useProgress } from '@/context/ProgressContext'; import { request } from '@/lib/api'; -import { chapters, getNextStage, getStage } from '@easylearn/core'; +import { chapters, getNextStage, getStage, getStageProgress } from '@easylearn/core'; // Phase 4:不再自己打 GET /api/progress,改讀 ProgressProvider 共用的 state——跟 Home tab // 是同一份進度,登入時的搬遷(migrate-local)也只會在 provider 裡觸發一次。 @@ -112,9 +112,7 @@ export default function ProfileScreen() { const stage = getStage(progress.xp); const nextStage = getNextStage(progress.xp); - const xpProgress = nextStage - ? Math.min(100, Math.round(((progress.xp - stage.min) / (nextStage.min - stage.min)) * 100)) - : 100; + const xpProgress = getStageProgress(progress.xp); return ( diff --git a/apps/mobile/components/AccountHeader.tsx b/apps/mobile/components/AccountHeader.tsx index 1a23f62..ab39369 100644 --- a/apps/mobile/components/AccountHeader.tsx +++ b/apps/mobile/components/AccountHeader.tsx @@ -250,7 +250,7 @@ export default function AccountHeader({ user }: AccountHeaderProps) { maxLength={40} autoFocus /> - + diff --git a/apps/mobile/components/Mascot.tsx b/apps/mobile/components/Mascot.tsx index 18be1fe..b73cec0 100644 --- a/apps/mobile/components/Mascot.tsx +++ b/apps/mobile/components/Mascot.tsx @@ -1,7 +1,7 @@ import { StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; -import { getNextStage, getStage } from '@easylearn/core'; +import { getNextStage, getStage, getStageProgress } from '@easylearn/core'; interface MascotProps { xp: number; @@ -13,9 +13,7 @@ interface MascotProps { export default function Mascot({ xp, 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/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 index 0a13145..fef4c19 100644 --- a/apps/mobile/screens/Stats.tsx +++ b/apps/mobile/screens/Stats.tsx @@ -1,3 +1,4 @@ +import { useRef } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; @@ -52,7 +53,6 @@ const activityLevel = (total: number): number => { const HEAT_COLORS = ['#88889912', '#2e78b730', '#2e78b760', '#2e78b790', '#2e78b7']; -const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; interface StatsProps { progress: Progress; @@ -63,6 +63,7 @@ interface StatsProps { 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 }), @@ -85,7 +86,7 @@ export default function Stats({ progress }: StatsProps) { const month = firstReal ? new Date(firstReal).getMonth() : lastMonth; const show = firstReal !== undefined && month !== lastMonth; lastMonth = month; - return show ? MONTH_LABELS[month] : ''; + return show ? String(month + 1) : ''; }); return ( @@ -119,11 +120,16 @@ export default function Stats({ progress }: StatsProps) { ))} - + heatmapScrollRef.current?.scrollToEnd({ animated: false })} + > {heatmapMonthLabels.map((label, i) => ( - + {label} ))} @@ -207,6 +213,9 @@ export default function Stats({ progress }: StatsProps) { 分科成效細分 + + 這裡是該章節所有作答的答對率(含隨機綜合練習),跟「每日刷題」頁的完成關卡數是不同的統計方式。 + {chapters.map((ch) => { const stat = chapterStats[ch.id] ?? { total: 0, correct: 0 }; @@ -272,6 +281,12 @@ const styles = StyleSheet.create({ opacity: 0.6, marginTop: 10, }, + sectionHint: { + fontSize: 11, + opacity: 0.5, + marginTop: -4, + lineHeight: 16, + }, heatmapCard: { borderRadius: 14, padding: 14, @@ -299,7 +314,11 @@ const styles = StyleSheet.create({ height: 14, }, heatmapMonthLabel: { - width: 11, + // 只顯示月份數字(不含「月」字),16px 寬夠放到 2 位數("12")也不會被 Text 自己的 + // 換行邏輯裁掉——先前用「讓文字視覺溢出方框」的寫法在這裡的水平 ScrollView 裡不可靠 + // (超出 ScrollView 量到的內容寬度那段一樣會被裁掉,尤其是捲到最右邊、沒有下一欄可以 + // 借用空間的最後一個月份),所以改成單純夠寬的固定寬度,不依賴溢出。 + width: 16, fontSize: 9, opacity: 0.55, }, @@ -308,6 +327,8 @@ const styles = StyleSheet.create({ gap: 3, }, heatmapCol: { + width: 16, + alignItems: 'center', gap: 3, }, heatmapCell: { diff --git a/apps/web/src/components/Mascot.tsx b/apps/web/src/components/Mascot.tsx index b173f22..61ec400 100644 --- a/apps/web/src/components/Mascot.tsx +++ b/apps/web/src/components/Mascot.tsx @@ -1,4 +1,4 @@ -import { getNextStage, getStage } from '@easylearn/core' +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/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/Quiz.tsx b/apps/web/src/screens/Quiz.tsx index ba9433b..4198c4b 100644 --- a/apps/web/src/screens/Quiz.tsx +++ b/apps/web/src/screens/Quiz.tsx @@ -99,7 +99,7 @@ const Quiz = ({

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