diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index cceba67..8644753 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,59 +1,70 @@ -import { SymbolView } from 'expo-symbols'; import { Tabs } from 'expo-router'; import { useAuth } from '@clerk/expo'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Colors from '@/constants/Colors'; -import { useColorScheme } from '@/components/useColorScheme'; +import Icon from '@/components/Icon'; +import TabBarButton from '@/components/TabBarButton'; +import { colors, fonts } from '@/constants/theme'; -// 對應 apps/web 的 Navbar.tsx 四個分頁(home/notes/stats/profile) +const TAB_BAR_CONTENT_HEIGHT = 62; + +// 對應 apps/web 的 Navbar.tsx 四個分頁(home/notes/stats/profile),樣式對照 +// index.css @media(max-width:640px) 底部 tab bar 那組規則(.navbar/.navbar-tab 系列)。 export default function TabLayout() { - const colorScheme = useColorScheme(); const { isSignedIn } = useAuth(); + const insets = useSafeAreaInsets(); return ( , }}> ( - - ), + tabBarIcon: ({ color }) => , }} /> ( - - ), + tabBarIcon: ({ color }) => , }} /> ( - - ), + tabBarIcon: ({ color }) => , }} /> ( - - ), + tabBarIcon: ({ color }) => , }} /> diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index cabf967..cafc6fd 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { View } from '@/components/Themed'; +import { colors } from '@/constants/theme'; import { useProgress } from '@/context/ProgressContext'; import Home from '@/screens/Home'; import ChapterMap from '@/screens/ChapterMap'; @@ -27,7 +28,7 @@ export default function HomeScreen() { if (!hydrated) { return ( - + ); } diff --git a/apps/mobile/app/(tabs)/notes.tsx b/apps/mobile/app/(tabs)/notes.tsx index 4ceae8b..d66eda6 100644 --- a/apps/mobile/app/(tabs)/notes.tsx +++ b/apps/mobile/app/(tabs)/notes.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { View } from '@/components/Themed'; +import { colors } from '@/constants/theme'; import { useProgress } from '@/context/ProgressContext'; import Notes from '@/screens/Notes'; import QuestionBook from '@/screens/QuestionBook'; @@ -34,7 +35,7 @@ export default function NotesScreen() { if (!hydrated) { return ( - + ); } diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 41a1fef..198b95a 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,13 +1,23 @@ import { useState } from 'react'; -import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet } from 'react-native'; +import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAuth, useSSO, useUser } from '@clerk/expo'; -import { Text, View } from '@/components/Themed'; +// 注意:View 刻意從 'react-native' 引入(預設透明),不是 Themed 的 View——Themed.View +// 沒有明確設定 backgroundColor 時會強制塗上整頁背景色 colors.bg(#04070a),拿來當純版面 +// 容器(例如卡片內部的 row/column)會蓋掉外層卡片自己的 colors.card(#0a1216)背景, +// 兩色非常接近、肉眼平常看不出來,但疊起來後有些區塊會比周圍明顯偏黑,就是這次「怪異黑底」 +// 的真正根因(用 RN 內建 Element Inspector 點出來確認過:backgroundColor 顯示的是 #04070a +// 而不是預期的 #0a1216)。這個檔案是目前唯一同時匯入 Themed 的 Text/View 又拿 View 當純版面 +// 容器用的地方,其他畫面都已經是用這裡的 plain View,只有這支需要修正。 +import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; import AccountHeader from '@/components/AccountHeader'; import Mascot from '@/components/Mascot'; import GrowthHistory from '@/components/GrowthHistory'; +import XpBar from '@/components/XpBar'; +import { PrimaryButton, TextButton, buttonTextStyles } from '@/components/Button'; +import { colors, fonts } from '@/constants/theme'; import { useProgress } from '@/context/ProgressContext'; import { request } from '@/lib/api'; import { chapters, getNextStage, getStage, getStageProgress } from '@easylearn/core'; @@ -26,7 +36,15 @@ export default function ProfileScreen() { const [showGrowth, setShowGrowth] = useState(false); const [deleting, setDeleting] = useState(false); const insets = useSafeAreaInsets(); - const containerStyle = [styles.container, { paddingTop: insets.top }]; + const { height: windowHeight } = useWindowDimensions(); + const containerStyle = [styles.container, { paddingTop: 24 }]; + // 上一輪的 bug:modalCard 自己的 maxHeight:'75%' 是相對於「背景遮罩層」(不含底部 tab bar, + // 比整個視窗矮)算的,但這裡用 useWindowDimensions() 量的是整個視窗高度,兩個基準對不起來, + // 算出來給 ScrollView 的數字可能比 modalCard 自己被夾住的高度還大,導致外層先把畫面切掉、 + // ScrollView 自己的 maxHeight 根本沒機會生效。這次把 modalCard 自己的高度也改成同一個基準 + // 算出來的明確數字(不再用 '75%' 字串),兩層用同一個數字,才不會互相打架。 + const growthModalCardMaxHeight = windowHeight * 0.75; + const growthModalScrollMaxHeight = growthModalCardMaxHeight - 80; // 80 ≈ 標題列+卡片上下 padding const handleSignIn = async () => { setError(null); @@ -82,8 +100,8 @@ export default function ProfileScreen() { // 蓋到 setActive 真的完成(isSignedIn 變 true)或失敗(跳回登入畫面+錯誤訊息)為止。 if (!isSignedIn && signingIn) { return ( - - + + ); } @@ -92,13 +110,13 @@ export default function ProfileScreen() { return ( - + 登入 EasyLearn 登入後可在多裝置同步學習進度;未登入也能繼續刷題,進度先存在這台裝置。 - - - 使用 Google 登入 - + + + 使用 Google 登入 + {error && {error}} @@ -115,161 +133,193 @@ export default function ProfileScreen() { const xpProgress = getStageProgress(progress.xp); return ( - - {!user ? ( - - ) : ( - <> - 個人資料 - - - + <> + {/* 這層包住 ScrollView 的 View 才是真正的安全區留白:paddingTop 是它自己的 padding, + 不屬於可捲動內容,所以捲動時不會被捲走,狀態列底下永遠有這個不透明的 colors.bg + 墊著。之前把 insets.top 放進 ScrollView 的 contentContainerStyle,那塊留白屬於 + 「捲動內容」的一部分,往上捲一下就跟著捲走了,底下的內容接著就直接畫到狀態列後面, + 跟狀態列的時間/電量文字重疊。 */} + + + {!user ? ( + + ) : ( + <> + 個人資料 + + + - 成長史 - - - - - - {stage.name} - - {nextStage ? `${progress.xp} / ${nextStage.min} XP` : `${progress.xp} XP・已達最終型態`} - - - - + 成長史 + + + + + + {stage.name} + + {nextStage ? `${progress.xp} / ${nextStage.min} XP` : `${progress.xp} XP・已達最終型態`} + + + + + + setShowGrowth(true)}> + 查看全部 › + - setShowGrowth(true)} hitSlop={8}> - 查看全部 › - - - setShowGrowth(false)}> - - - - 成長史 - setShowGrowth(false)} hitSlop={8}> - - - - - - + 學習統計 + {!hydrated ? ( + + ) : ( + + + + + - - + )} - 學習統計 - {!hydrated ? ( - - ) : ( - - - - - + 帳號設定 + + signOut()}> + + + 登出 + + + + + + + + {deleting ? '刪除中…' : '刪除帳號'} + + + + - )} + + )} + + - 帳號設定 - - signOut()}> - - - 登出 - - - - - - - - {deleting ? '刪除中…' : '刪除帳號'} - + {/* 成長史彈窗刻意不用 RN 的 :Android 上 Modal 是另開一個系統 Dialog 視窗, + 不會繼承 app 主視窗的 edge-to-edge/安全區設定,導致底部導覽列那塊區域露出系統預設的 + 純黑背景(實機回報過這個視覺問題)。改成同一棵 React tree 裡的絕對定位覆蓋層, + 確保跟其他畫面共用同一個視窗、同一份安全區設定。 + 背景(點擊關閉)跟卡片本體刻意是「手足」關係,不是卡片包在背景 Pressable 裡面: + 上一版把整個卡片放進背景 Pressable 底下,結果 (1) Pressable 包住 ScrollView 讓 + iOS 滑不動,(2) 拿掉卡片自己的 stopPropagation 保護後,點卡片內容也會被判定成 + 點到背景、直接關閉彈窗。改成背景 Pressable 蓋滿全螢幕、卡片容器用 + pointerEvents="box-none" 疊在背景「上面」(手足關係,卡片不是背景的子節點): + 點卡片本體時,RN 的觸控命中測試會直接打到卡片自己的畫面元素,不會經過背景 + Pressable;只有點卡片以外、真正露出背景的空白處,才會落到背景 Pressable 上觸發關閉。 */} + {user && showGrowth && ( + + setShowGrowth(false)} /> + + + + 成長史 + setShowGrowth(false)} hitSlop={8}> + + - - + + + + - + )} - + ); } function StatItem({ label, value }: { label: string; value: string | number }) { return ( - {value} {label} + {value} ); } const styles = StyleSheet.create({ + screenWrap: { + flex: 1, + backgroundColor: colors.bg, + }, container: { - padding: 16, - gap: 14, + paddingHorizontal: 24, + paddingBottom: 40, + backgroundColor: colors.bg, }, loginContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', - padding: 20, + padding: 24, + backgroundColor: colors.bg, }, loginBox: { alignItems: 'center', - gap: 10, + gap: 8, maxWidth: 340, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.25)', + paddingVertical: 36, + paddingHorizontal: 28, }, title: { - fontSize: 20, - fontWeight: 'bold', + fontFamily: fonts.mono.bold, + fontSize: 22, + fontWeight: '700', + color: colors.inkStrong, + marginTop: 4, }, subtitle: { - fontSize: 14, - opacity: 0.6, + fontFamily: fonts.sans.regular, + fontSize: 13, + lineHeight: 22, + color: colors.inkSoft, textAlign: 'center', + marginBottom: 4, + }, + loginBtn: { + marginTop: 8, }, spinner: { marginTop: 8, }, error: { - color: '#e5484d', + fontFamily: fonts.sans.regular, + color: colors.wrong, fontSize: 13, textAlign: 'center', }, - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - marginTop: 12, - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 10, - backgroundColor: '#2e78b7', - }, - buttonText: { - color: '#fff', - fontWeight: '600', - }, hero: { - borderRadius: 16, - padding: 18, - backgroundColor: '#88889910', - gap: 4, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + padding: 22, }, heroXpRow: { flexDirection: 'row', alignItems: 'center', - gap: 14, + gap: 16, }, heroXpInfo: { flex: 1, @@ -282,40 +332,48 @@ const styles = StyleSheet.create({ gap: 8, }, heroXpName: { + fontFamily: fonts.mono.bold, fontWeight: '700', fontSize: 13, + color: colors.primary, }, heroXpCount: { + fontFamily: fonts.mono.regular, fontSize: 11, - opacity: 0.6, - }, - xpBar: { - height: 6, - borderRadius: 3, - backgroundColor: '#88889920', - overflow: 'hidden', - marginTop: 6, + color: colors.inkSoft, }, - xpBarFill: { - height: '100%', - backgroundColor: '#2e78b7', + xpBarWrap: { + marginTop: 8, }, - growthLink: { - fontSize: 12, - fontWeight: '700', - color: '#2e78b7', + modalRoot: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, }, modalBackdrop: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(4, 7, 10, 0.75)', + }, + modalCardWrap: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, justifyContent: 'flex-end', - backgroundColor: 'rgba(0,0,0,0.6)', }, modalCard: { maxHeight: '75%', - borderTopLeftRadius: 20, - borderTopRightRadius: 20, padding: 20, - backgroundColor: '#121212', + backgroundColor: colors.card, + borderTopWidth: 1, + borderColor: colors.optionBorder, }, modalHeader: { flexDirection: 'row', @@ -324,42 +382,49 @@ const styles = StyleSheet.create({ marginBottom: 14, }, modalTitle: { + fontFamily: fonts.mono.bold, fontSize: 16, fontWeight: '700', + color: colors.inkStrong, }, sectionTitle: { + fontFamily: fonts.mono.bold, fontSize: 13, fontWeight: '700', - opacity: 0.6, - marginTop: 4, + letterSpacing: 1.5, + color: 'rgba(95, 240, 224, 0.65)', + marginTop: 24, + marginBottom: 12, }, + // 對照 index.css @media(max-width:480px) 的 .profile-stat-grid 手機版變體:手機寬度幾乎 + // 都落在這個斷點內,直接採用單欄、標籤在左數值在右的橫列樣式(不是桌面版 4 欄 grid)。 statGrid: { - flexDirection: 'row', - flexWrap: 'wrap', gap: 12, }, statItem: { - flexBasis: '47%', - flexGrow: 1, + flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', - borderRadius: 14, - padding: 14, - backgroundColor: '#88889910', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + paddingVertical: 14, + paddingHorizontal: 16, }, statValue: { + fontFamily: fonts.mono.extraBold, fontSize: 18, - fontWeight: 'bold', + fontWeight: '800', + color: colors.cyan, }, statLabel: { + fontFamily: fonts.sans.regular, fontSize: 12, - opacity: 0.6, - marginTop: 2, + color: colors.inkFaint, }, accountList: { - borderRadius: 12, - overflow: 'hidden', borderWidth: 1, - borderColor: '#88889918', + borderColor: colors.optionBorder, }, accountItem: { flexDirection: 'row', @@ -368,7 +433,7 @@ const styles = StyleSheet.create({ paddingVertical: 16, paddingHorizontal: 18, borderBottomWidth: 1, - borderBottomColor: '#88889912', + borderBottomColor: 'rgba(95, 240, 224, 0.12)', }, accountItemDanger: { borderBottomWidth: 0, @@ -379,9 +444,11 @@ const styles = StyleSheet.create({ gap: 8, }, accountItemText: { + fontFamily: fonts.sans.regular, fontSize: 14, + color: colors.ink, }, accountItemDangerText: { - color: '#e5484d', + color: colors.wrong, }, }); diff --git a/apps/mobile/app/(tabs)/stats.tsx b/apps/mobile/app/(tabs)/stats.tsx index 0c38373..dc0775d 100644 --- a/apps/mobile/app/(tabs)/stats.tsx +++ b/apps/mobile/app/(tabs)/stats.tsx @@ -2,6 +2,7 @@ import { ActivityIndicator, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { View } from '@/components/Themed'; +import { colors } from '@/constants/theme'; import { useProgress } from '@/context/ProgressContext'; import Stats from '@/screens/Stats'; @@ -12,7 +13,7 @@ export default function StatsScreen() { if (!hydrated) { return ( - + ); } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index b81a155..fe87f2b 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,4 +1,16 @@ import { ClerkProvider } from '@clerk/expo'; +import { + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + JetBrainsMono_700Bold, + JetBrainsMono_800ExtraBold, +} from '@expo-google-fonts/jetbrains-mono'; +import { + NotoSansTC_400Regular, + NotoSansTC_500Medium, + NotoSansTC_700Bold, + NotoSansTC_900Black, +} from '@expo-google-fonts/noto-sans-tc'; import { useFonts } from 'expo-font'; import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; @@ -36,8 +48,18 @@ export const unstable_settings = { SplashScreen.preventAutoHideAsync(); export default function RootLayout() { + // 對照 apps/web/src/app/layout.tsx 從 Google Fonts 載入的同一組字型/weight + // (JetBrains Mono 400/500/700/800、Noto Sans TC 400/500/700/900), + // 樣式裡要用哪個 weight 直接引用 constants/theme.ts 的 fonts.mono/fonts.sans。 const [loaded, error] = useFonts({ - SpaceMono: require('@/assets/fonts/SpaceMono-Regular.ttf'), + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + JetBrainsMono_700Bold, + JetBrainsMono_800ExtraBold, + NotoSansTC_400Regular, + NotoSansTC_500Medium, + NotoSansTC_700Bold, + NotoSansTC_900Black, }); // Expo Router uses Error Boundaries to catch errors in the navigation tree. diff --git a/apps/mobile/components/AccountHeader.tsx b/apps/mobile/components/AccountHeader.tsx index ab39369..cb03e71 100644 --- a/apps/mobile/components/AccountHeader.tsx +++ b/apps/mobile/components/AccountHeader.tsx @@ -7,6 +7,8 @@ import { useUser } from '@clerk/expo'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import { buttonTextStyles } from '@/components/Button'; +import { colors, fonts } from '@/constants/theme'; type ClerkUser = NonNullable['user']>; @@ -231,12 +233,12 @@ export default function AccountHeader({ user }: AccountHeaderProps) { ) : user.firstName ? ( {user.firstName[0].toUpperCase()} ) : ( - + )} - + @@ -251,17 +253,17 @@ export default function AccountHeader({ user }: AccountHeaderProps) { autoFocus /> - + - + ) : ( {user.firstName || '未命名'} setNameEditing(true)} hitSlop={8}> - + )} @@ -278,19 +280,19 @@ export default function AccountHeader({ user }: AccountHeaderProps) { step={0.05} value={pos.scale} onValueChange={(v) => setPos((prev) => ({ ...prev, scale: clamp(v, MIN_SCALE, MAX_SCALE) }))} - minimumTrackTintColor="#2e78b7" - maximumTrackTintColor="#88889940" - thumbTintColor="#ffffff" + minimumTrackTintColor={colors.primary} + maximumTrackTintColor="rgba(255, 180, 84, 0.25)" + thumbTintColor={colors.primary} /> - - 完成 + + 完成 - - 取消 + + 取消 拖曳照片可移動位置,拉桿可縮放,完成後按「完成」儲存。 @@ -317,9 +319,9 @@ const styles = StyleSheet.create({ height: AVATAR_SIZE, borderRadius: AVATAR_SIZE / 2, overflow: 'hidden', - backgroundColor: '#88889918', + backgroundColor: colors.optionBg, borderWidth: 2, - borderColor: '#2e78b7', + borderColor: colors.cyan, alignItems: 'center', justifyContent: 'center', }, @@ -328,20 +330,24 @@ const styles = StyleSheet.create({ // (borderRadius 50%)上是已知的平台限制,繞著曲線的虛線間距算不出來,實機上只會 // 斷斷續續露出一小段,不是我們的樣式寫錯——改用純色框,一樣能清楚表示進入編輯模式。 borderWidth: 3, - borderColor: '#ffb454', + borderColor: colors.primary, }, avatarInitial: { + fontFamily: fonts.mono.bold, fontWeight: '700', fontSize: 26, + color: colors.cyan, }, avatarEditBtn: { position: 'absolute', bottom: -2, right: -2, - width: 22, - height: 22, - borderRadius: 11, - backgroundColor: '#2e78b7', + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.primary, + borderWidth: 2, + borderColor: colors.card, alignItems: 'center', justifyContent: 'center', }, @@ -355,8 +361,10 @@ const styles = StyleSheet.create({ gap: 8, }, name: { + fontFamily: fonts.sans.bold, fontSize: 17, fontWeight: '700', + color: colors.ink, }, nameEditRow: { flexDirection: 'row', @@ -365,17 +373,18 @@ const styles = StyleSheet.create({ }, nameInput: { flex: 1, + fontFamily: fonts.sans.medium, fontSize: 14, - fontWeight: '600', - color: '#e6e6e6', + color: colors.ink, borderWidth: 1, - borderColor: '#88889940', - borderRadius: 8, + borderColor: colors.optionBorder, paddingHorizontal: 8, paddingVertical: 4, + backgroundColor: colors.bg, }, repositionPanel: { gap: 10, + marginTop: 10, }, zoomRow: { flexDirection: 'row', @@ -394,12 +403,9 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 5, }, - repositionBtnText: { - fontSize: 12, - fontWeight: '700', - }, repositionNote: { + fontFamily: fonts.sans.regular, fontSize: 12, - opacity: 0.6, + color: colors.inkSoft, }, }); diff --git a/apps/mobile/components/Button.tsx b/apps/mobile/components/Button.tsx new file mode 100644 index 0000000..d992868 --- /dev/null +++ b/apps/mobile/components/Button.tsx @@ -0,0 +1,118 @@ +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; + +import NotchedView from '@/components/NotchedView'; +import { colors, fonts, notchSm } from '@/constants/theme'; + +interface ButtonProps { + onPress?: () => void; + disabled?: boolean; + children: ReactNode; + style?: StyleProp; +} + +interface PrimaryButtonProps extends ButtonProps { + contentStyle?: StyleProp; +} + +// 對照 index.css 的 .primary-btn:缺角(notch-sm,切左上/右下)、實心 primary 底色。 +// contentStyle 讓呼叫端覆寫 padding(例如 .next-btn 是 padding:14px,不是預設的 16px 32px)。 +export function PrimaryButton({ onPress, disabled, children, style, contentStyle }: PrimaryButtonProps) { + return ( + + + {children} + + + ); +} + +// 對照 .secondary-btn:透明底、cyan 邊框,直角矩形(--radius:0)不需要缺角。 +export function SecondaryButton({ onPress, disabled, children, style }: ButtonProps) { + return ( + + {children} + + ); +} + +// 對照 .text-btn/.danger-btn:純文字按鈕。danger(.danger-btn 的 --wrong 色)由呼叫端 +// 透過 buttonTextStyles.textDanger/Icon color 自行套用在 children 上,這裡只負責版面。 +export function TextButton({ onPress, disabled, children, style }: ButtonProps) { + return ( + + {children} + + ); +} + +export const buttonTextStyles = StyleSheet.create({ + primary: { + fontFamily: fonts.mono.bold, + fontSize: 16, + fontWeight: '700', + letterSpacing: 0.5, + color: colors.primaryInk, + }, + secondary: { + fontFamily: fonts.mono.bold, + fontSize: 14, + fontWeight: '700', + color: colors.ink, + }, + text: { + fontFamily: fonts.mono.bold, + fontSize: 12, + fontWeight: '700', + color: colors.primary, + }, + textDanger: { + fontFamily: fonts.mono.bold, + fontSize: 12, + fontWeight: '700', + color: colors.wrong, + }, +}); + +const styles = StyleSheet.create({ + // 故意不設 alignSelf:讓按鈕跟著父層的 alignItems 走(父層 center 就置中縮成內容寬, + // 父層預設 stretch 就撐滿寬度),對應網頁版 inline-flex 元素被 flex 父層擺放的行為。 + primaryWrap: {}, + primaryContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 16, + paddingHorizontal: 32, + }, + secondary: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.secondaryBorder, + paddingVertical: 13, + paddingHorizontal: 28, + }, + text: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + padding: 4, + }, + disabled: { + opacity: 0.55, + }, +}); diff --git a/apps/mobile/components/CodeBlock.tsx b/apps/mobile/components/CodeBlock.tsx index 3b60930..b53a5db 100644 --- a/apps/mobile/components/CodeBlock.tsx +++ b/apps/mobile/components/CodeBlock.tsx @@ -1,30 +1,69 @@ +import type { ReactNode } from 'react'; import { ScrollView, StyleSheet, Text } from 'react-native'; +import { colors, fonts } from '@/constants/theme'; + interface CodeBlockProps { code: string; } -// Phase 3 MVP:純等寬字體顯示,不做 web 版 CodeBlock.tsx 那種語法上色 +// 對照 apps/web CodeBlock.tsx 的輕量語法上色:註解、字串、關鍵字、數字(同一份 regex) +const TOKEN_RE = + /(\/\/[^\n]*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`)|\b(const|let|var|function|return|if|else|for|of|in|new|typeof|class|import|export|from|true|false|null|undefined)\b|(\b\d+(?:\.\d+)?\b)/g; + +const highlight = (code: string): ReactNode[] => { + const matches = Array.from(code.matchAll(TOKEN_RE)); + const { parts, last } = matches.reduce<{ parts: ReactNode[]; last: number }>( + (acc, m, i) => { + const [text, comment, string, keyword, number] = m; + const style = comment + ? styles.tokComment + : string + ? styles.tokString + : keyword + ? styles.tokKeyword + : number + ? styles.tokNumber + : undefined; + if (m.index > acc.last) acc.parts.push(code.slice(acc.last, m.index)); + acc.parts.push( + + {text} + , + ); + acc.last = m.index + text.length; + return acc; + }, + { parts: [], last: 0 }, + ); + return last < code.length ? [...parts, code.slice(last)] : parts; +}; + export default function CodeBlock({ code }: CodeBlockProps) { if (!code) return null; return ( - {code} + {highlight(code)} ); } const styles = StyleSheet.create({ wrap: { - backgroundColor: '#1e1e2e', - borderRadius: 8, - marginVertical: 10, + backgroundColor: colors.codeBg, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.15)', + marginVertical: 8, }, code: { - color: '#e2e2f0', - fontFamily: 'monospace', + color: colors.ink, + fontFamily: fonts.mono.regular, fontSize: 13, - lineHeight: 19, - padding: 12, + lineHeight: 23, + padding: 16, }, + tokKeyword: { color: '#c792ea' }, + tokString: { color: '#c3e88d' }, + tokNumber: { color: colors.primary }, + tokComment: { color: colors.inkFaint }, }); diff --git a/apps/mobile/components/GrowthHistory.tsx b/apps/mobile/components/GrowthHistory.tsx index 3dd0d29..be3a772 100644 --- a/apps/mobile/components/GrowthHistory.tsx +++ b/apps/mobile/components/GrowthHistory.tsx @@ -1,13 +1,15 @@ import { StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; +import { colors, fonts } from '@/constants/theme'; import { STAGES, getStage } from '@easylearn/core'; interface GrowthHistoryProps { xp: number; } -// 對照 apps/web 的 GrowthHistory.tsx:吉祥物成長史清單 +// 對照 apps/web 的 GrowthHistory.tsx:吉祥物成長史清單。mobile 這裡改用 Modal 彈窗顯示 +// (見 app/(tabs)/profile.tsx),本體清單樣式仍照抄 index.css 的 .growth-history 系列。 export default function GrowthHistory({ xp }: GrowthHistoryProps) { const current = getStage(xp); @@ -19,7 +21,7 @@ export default function GrowthHistory({ xp }: GrowthHistoryProps) { return ( {stage.emoji} @@ -43,17 +45,19 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 10, - padding: 10, - borderRadius: 10, - backgroundColor: '#88889910', + padding: 8, + paddingHorizontal: 10, + backgroundColor: colors.optionBg, + borderWidth: 1, + borderColor: 'transparent', + opacity: 0.5, }, - itemLocked: { - opacity: 0.4, + itemReached: { + opacity: 1, }, itemCurrent: { - backgroundColor: '#2e78b722', - borderWidth: 1, - borderColor: '#2e78b755', + backgroundColor: colors.badgeBg, + borderColor: colors.primary, }, emoji: { fontSize: 22, @@ -64,11 +68,14 @@ const styles = StyleSheet.create({ gap: 2, }, name: { + fontFamily: fonts.sans.medium, fontSize: 13, fontWeight: '600', + color: colors.ink, }, xp: { + fontFamily: fonts.mono.regular, fontSize: 11, - opacity: 0.55, + color: colors.inkFaint, }, }); diff --git a/apps/mobile/components/Icon.tsx b/apps/mobile/components/Icon.tsx index 554badd..a5a2f8c 100644 --- a/apps/mobile/components/Icon.tsx +++ b/apps/mobile/components/Icon.tsx @@ -76,10 +76,13 @@ interface IconProps { name: IconName; size?: number; color?: string; + fill?: string; } -export default function Icon({ name, size = 18, color }: IconProps) { +// fill 對照網頁版 Icons.tsx 的 `icon-filled` class(星號收藏時 fill=currentColor 塗滿), +// lucide-react-native 的圖示元件本來就接受 fill prop,不用另外處理。 +export default function Icon({ name, size = 18, color, fill = 'none' }: IconProps) { const theme = useColorScheme(); const IconComponent = ICONS[name]; - return ; + return ; } diff --git a/apps/mobile/components/Mascot.tsx b/apps/mobile/components/Mascot.tsx index b73cec0..9aa3fee 100644 --- a/apps/mobile/components/Mascot.tsx +++ b/apps/mobile/components/Mascot.tsx @@ -1,6 +1,8 @@ import { StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; +import XpBar from '@/components/XpBar'; +import { colors, fonts } from '@/constants/theme'; import { getNextStage, getStage, getStageProgress } from '@easylearn/core'; interface MascotProps { @@ -16,14 +18,12 @@ export default function Mascot({ xp, size = 'lg' }: MascotProps) { const progressToNext = getStageProgress(xp); return ( - + {stage.emoji} {size === 'lg' && ( <> {stage.name} - - - + {next ? `${xp} XP・再 ${next.min - xp} XP 進化成 ${next.emoji}` : `${xp} XP・已達最終型態!`} @@ -36,36 +36,31 @@ export default function Mascot({ xp, size = 'lg' }: MascotProps) { const styles = StyleSheet.create({ container: { alignItems: 'center', + marginBottom: 8, + }, + containerSm: { + marginBottom: 0, }, emoji: { - fontSize: 72, - lineHeight: 84, + fontSize: 96, + lineHeight: 115, }, emojiSm: { fontSize: 32, lineHeight: 38, }, name: { + fontFamily: fonts.mono.bold, fontWeight: '700', - fontSize: 15, - marginTop: 4, - }, - xpBar: { - width: 200, - height: 10, - borderRadius: 5, - backgroundColor: '#88889920', - overflow: 'hidden', + fontSize: 18, + color: colors.inkStrong, marginTop: 8, }, - xpBarFill: { - height: '100%', - backgroundColor: '#2e78b7', - }, hint: { - fontSize: 12, - opacity: 0.6, - marginTop: 6, + fontFamily: fonts.sans.regular, + fontSize: 13, + color: colors.inkSoft, + marginTop: 8, textAlign: 'center', }, }); diff --git a/apps/mobile/components/NotchedView.tsx b/apps/mobile/components/NotchedView.tsx new file mode 100644 index 0000000..87f198b --- /dev/null +++ b/apps/mobile/components/NotchedView.tsx @@ -0,0 +1,83 @@ +import { useCallback, useState, type ReactNode } from 'react'; +import { StyleSheet, View, type LayoutChangeEvent, type StyleProp, type ViewStyle } from 'react-native'; +import Svg, { Polygon } from 'react-native-svg'; + +// 對照 apps/web index.css 的 clip-path 缺角造型:RN 沒有原生 clip-path, +// 用 react-native-svg 畫一個缺角多邊形當背景,取代 CSS 的 clip-path: polygon(...)。 +// 'tr-bl' = 切右上/左下角(.question-card/.note-card/.login-box 用,notch=18) +// 'tl-br' = 切左上/右下角(.primary-btn 用,notch-sm=12) +export type NotchCorners = 'tr-bl' | 'tl-br'; + +// inset:邊框寬度的一半,讓 stroke(畫在路徑正中央、一半往外一半往內)完全落在 Svg 畫布內, +// 不會被容器邊界裁掉一半。inset=0(無邊框)時跟原本行為完全一致。 +const buildPoints = (w: number, h: number, n: number, corners: NotchCorners, inset: number = 0): string => { + const iw = w - inset * 2; + const ih = h - inset * 2; + const notch = Math.min(n, iw / 2, ih / 2); + const pts = + corners === 'tr-bl' + ? [ + [0, 0], + [iw - notch, 0], + [iw, notch], + [iw, ih], + [notch, ih], + [0, ih - notch], + ] + : [ + [notch, 0], + [iw, 0], + [iw, ih - notch], + [iw - notch, ih], + [0, ih], + [0, notch], + ]; + return pts.map((p) => `${p[0] + inset},${p[1] + inset}`).join(' '); +}; + +interface NotchedViewProps { + notch?: number; + corners?: NotchCorners; + backgroundColor: string; + borderColor?: string; + borderWidth?: number; + style?: StyleProp; + contentStyle?: StyleProp; + children?: ReactNode; +} + +// 用法:外層 style 只放寬度/margin 等排版屬性,padding 一律放 contentStyle—— +// 這樣 Svg 背景(絕對定位鋪滿外層)才能涵蓋到完整的 padding 區域,缺角才會切在最外緣。 +export default function NotchedView({ + notch = 18, + corners = 'tr-bl', + backgroundColor, + borderColor = 'transparent', + borderWidth = 0, + style, + contentStyle, + children, +}: NotchedViewProps) { + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + setSize((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height })); + }, []); + + return ( + + {size ? ( + + + + ) : null} + {children} + + ); +} diff --git a/apps/mobile/components/QuestionCard.tsx b/apps/mobile/components/QuestionCard.tsx index 28164cd..32413fb 100644 --- a/apps/mobile/components/QuestionCard.tsx +++ b/apps/mobile/components/QuestionCard.tsx @@ -4,6 +4,9 @@ import { Linking, Pressable, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; import CodeBlock from '@/components/CodeBlock'; +import NotchedView from '@/components/NotchedView'; +import { PrimaryButton } from '@/components/Button'; +import { colors, fonts, notch } from '@/constants/theme'; import { shuffle, TYPE_META, type Question } from '@easylearn/core'; interface QuestionCardProps { @@ -31,17 +34,24 @@ export default function QuestionCard({ const options = useMemo(() => shuffle(question.options), [question.options]); return ( - + - + {typeMeta.label} {question.topic} - onToggleSave(question.id)} hitSlop={8} style={{ opacity: saved ? 1 : 0.35 }}> - + onToggleSave(question.id)} hitSlop={8} style={styles.saveBtn}> + @@ -64,7 +74,15 @@ export default function QuestionCard({ answered && !isAnswer && !isPicked && styles.optionDimmed, ]} > - {opt.text} + + {opt.text} + ); })} @@ -72,117 +90,165 @@ export default function QuestionCard({ {answered && ( - - {correct ? '答對了!+10 XP' : '沒關係,弄懂它才是重點 +2 XP'} - + + + + {correct ? '答對了!+10 XP' : '沒關係,弄懂它才是重點 +2 XP'} + + {question.explanation} {question.docs && ( - Linking.openURL(question.docs).catch(() => {})}> - 延伸閱讀:官方文件 + Linking.openURL(question.docs).catch(() => {})} style={styles.docsLink}> + + 延伸閱讀:官方文件 )} - + {isLast ? '看結算' : '下一題'} - + + )} - + ); } const styles = StyleSheet.create({ card: { - gap: 4, + padding: 22, }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, - marginBottom: 8, + marginBottom: 14, + flexWrap: 'wrap', }, typeBadge: { flexDirection: 'row', alignItems: 'center', - gap: 4, - backgroundColor: '#2e78b722', - borderRadius: 999, + gap: 6, + backgroundColor: colors.badgeBg, + borderWidth: 1, + borderColor: 'rgba(255, 180, 84, 0.4)', paddingHorizontal: 10, - paddingVertical: 4, + paddingVertical: 5, }, typeBadgeText: { - fontSize: 12, - fontWeight: '600', + fontFamily: fonts.mono.bold, + fontSize: 11, + fontWeight: '700', + color: colors.primary, }, topicLabel: { flex: 1, + fontFamily: fonts.sans.regular, fontSize: 12, - opacity: 0.6, + color: colors.inkSoft, + }, + saveBtn: { + marginLeft: 'auto', + padding: 4, }, prompt: { + fontFamily: fonts.sans.bold, fontSize: 16, - fontWeight: '600', - lineHeight: 22, + fontWeight: '700', + color: colors.inkStrong, + lineHeight: 26, + marginBottom: 14, }, options: { - gap: 8, - marginTop: 6, + gap: 10, }, optionBtn: { + backgroundColor: colors.optionBg, borderWidth: 1, - borderColor: '#8888991a', - borderRadius: 10, - paddingVertical: 12, - paddingHorizontal: 14, - backgroundColor: '#88889908', + borderColor: colors.optionBorder, + paddingVertical: 14, + paddingHorizontal: 16, }, optionCorrect: { - borderColor: '#2f9e44', - backgroundColor: '#2f9e4422', + backgroundColor: colors.correctFill, + borderColor: colors.correct, }, optionWrong: { - borderColor: '#e5484d', - backgroundColor: '#e5484d22', + backgroundColor: colors.wrongFill, + borderColor: colors.wrong, }, optionDimmed: { - opacity: 0.5, + opacity: 0.4, }, optionText: { - fontSize: 14, + fontFamily: fonts.sans.regular, + fontSize: 14.5, + lineHeight: 21, + color: colors.ink, + }, + optionTextCorrect: { + color: colors.correct, + fontWeight: '700', + }, + optionTextWrong: { + color: colors.wrong, + fontWeight: '700', }, feedback: { - marginTop: 12, - borderRadius: 10, - padding: 14, - gap: 8, + marginTop: 16, + borderWidth: 1, + padding: 18, }, feedbackCorrect: { - backgroundColor: '#2f9e4418', + backgroundColor: colors.correctSoft, + borderColor: 'rgba(95, 240, 224, 0.3)', }, feedbackWrong: { - backgroundColor: '#e5484d18', + backgroundColor: colors.wrongSoft, + borderColor: 'rgba(255, 92, 114, 0.3)', + }, + feedbackTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 10, }, feedbackTitle: { - fontSize: 15, + fontFamily: fonts.mono.bold, fontWeight: '700', + fontSize: 14, }, feedbackExplanation: { - fontSize: 14, - lineHeight: 20, + fontFamily: fonts.sans.regular, + fontSize: 13.5, + lineHeight: 23, + color: colors.inkSoft, + marginBottom: 12, }, docsLink: { - fontSize: 13, - color: '#2e78b7', - textDecorationLine: 'underline', - }, - nextBtn: { - marginTop: 4, - backgroundColor: '#2e78b7', - borderRadius: 10, - paddingVertical: 12, + flexDirection: 'row', alignItems: 'center', + gap: 6, + marginBottom: 14, + }, + docsLinkText: { + fontFamily: fonts.sans.regular, + fontSize: 12.5, + color: colors.cyan, + }, + nextBtnWrap: { + width: '100%', + }, + nextBtnContent: { + padding: 14, }, nextBtnText: { - color: '#fff', + fontFamily: fonts.mono.bold, + fontSize: 14, fontWeight: '700', + color: colors.primaryInk, }, }); diff --git a/apps/mobile/components/TabBarButton.tsx b/apps/mobile/components/TabBarButton.tsx new file mode 100644 index 0000000..265597c --- /dev/null +++ b/apps/mobile/components/TabBarButton.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, type PressableProps, type PressableStateCallbackType } from 'react-native'; + +import { colors } from '@/constants/theme'; + +interface TabBarButtonProps extends PressableProps { + children?: ReactNode; +} + +// 對照 index.css @media(max-width:640px) 的 .navbar-tab.is-active:選中的分頁上緣露出 +// 2px primary 色細線+淡淡的底色,其餘分頁保持透明。expo-router 的 tabBarItemStyle +// 沒辦法依 focus 狀態動態切換,所以用 tabBarButton 換掉預設按鈕自己控制這段樣式。 +export default function TabBarButton({ children, style, accessibilityState, ...rest }: TabBarButtonProps) { + const focused = accessibilityState?.selected; + return ( + [ + styles.base, + focused && styles.active, + typeof style === 'function' ? style(state) : style, + ]} + > + {children} + + ); +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + paddingVertical: 9, + borderTopWidth: 2, + borderTopColor: 'transparent', + }, + active: { + borderTopColor: colors.primary, + backgroundColor: 'rgba(255, 180, 84, 0.08)', + }, +}); diff --git a/apps/mobile/components/Themed.tsx b/apps/mobile/components/Themed.tsx index e277e07..4a2ba15 100644 --- a/apps/mobile/components/Themed.tsx +++ b/apps/mobile/components/Themed.tsx @@ -7,6 +7,7 @@ import { Text as DefaultText, View as DefaultView } from 'react-native'; import { useColorScheme } from './useColorScheme'; import Colors from '@/constants/Colors'; +import { fonts } from '@/constants/theme'; type ThemeProps = { lightColor?: string; @@ -34,7 +35,7 @@ export function Text(props: TextProps) { const { style, lightColor, darkColor, ...otherProps } = props; const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - return ; + return ; } export function View(props: ViewProps) { diff --git a/apps/mobile/components/XpBar.tsx b/apps/mobile/components/XpBar.tsx new file mode 100644 index 0000000..cf5bbe2 --- /dev/null +++ b/apps/mobile/components/XpBar.tsx @@ -0,0 +1,35 @@ +import { StyleSheet, View, type DimensionValue } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +import { colors } from '@/constants/theme'; + +interface XpBarProps { + progress: number; // 0-100 + height?: number; + width?: DimensionValue; +} + +// 對照 index.css 的 .xp-bar/.xp-bar-fill:cyan→primary 的漸層進度條,Mascot 與 Profile +// 的「經驗值」卡片共用同一份,避免兩處各刻一次漸層邏輯。 +export default function XpBar({ progress, height = 12, width = 220 }: XpBarProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + track: { + backgroundColor: colors.track, + overflow: 'hidden', + }, + fill: { + height: '100%', + }, +}); diff --git a/apps/mobile/constants/Colors.ts b/apps/mobile/constants/Colors.ts index ac788c4..c402e8d 100644 --- a/apps/mobile/constants/Colors.ts +++ b/apps/mobile/constants/Colors.ts @@ -1,19 +1,20 @@ -const tintColorLight = '#2f95dc'; -const tintColorDark = '#fff'; +import { colors } from '@/constants/theme'; +// useColorScheme() 目前寫死回傳 'dark',light 這組實際上不會被用到, +// 保留欄位只是配合 Themed.tsx 既有的 light/dark 型別介面。 export default { light: { text: '#000', background: '#fff', - tint: tintColorLight, + tint: colors.primary, tabIconDefault: '#ccc', - tabIconSelected: tintColorLight, + tabIconSelected: colors.primary, }, dark: { - text: '#e6e6e6', - background: '#121212', - tint: tintColorDark, - tabIconDefault: '#888', - tabIconSelected: tintColorDark, + text: colors.ink, + background: colors.bg, + tint: colors.primary, + tabIconDefault: colors.navbarTabInactive, + tabIconSelected: colors.primary, }, }; diff --git a/apps/mobile/constants/theme.ts b/apps/mobile/constants/theme.ts new file mode 100644 index 0000000..e683219 --- /dev/null +++ b/apps/mobile/constants/theme.ts @@ -0,0 +1,66 @@ +// 對照 apps/web/src/index.css 的 :root CSS variables,逐一搬成 RN 可用的常數。 +// 顏色/字型/notch 尺寸都要跟網頁版完全一致,改動這裡務必回頭比對 index.css 有沒有跟著變。 +export const colors = { + bg: '#04070a', + card: '#0a1216', + ink: '#dff8f4', + inkStrong: '#eafffb', + inkSoft: 'rgba(223, 248, 244, 0.55)', + inkFaint: 'rgba(223, 248, 244, 0.35)', + primary: '#ffb454', + primaryDeep: '#cc8f38', + primaryInk: '#04070a', + cyan: '#5ff0e0', + correct: '#5ff0e0', + correctFill: 'rgba(95, 240, 224, 0.12)', + correctSoft: 'rgba(95, 240, 224, 0.08)', + wrong: '#ff5c72', + wrongFill: 'rgba(255, 92, 114, 0.12)', + wrongSoft: 'rgba(255, 92, 114, 0.08)', + locked: 'rgba(223, 248, 244, 0.35)', + codeBg: '#04070a', + track: 'rgba(95, 240, 224, 0.1)', + optionBg: '#0d1319', + optionBorder: 'rgba(95, 240, 224, 0.18)', + secondaryBorder: 'rgba(95, 240, 224, 0.4)', + badgeBg: 'rgba(255, 180, 84, 0.15)', + navbarBg: '#060b0e', + navbarBorder: 'rgba(95, 240, 224, 0.25)', + navbarTabInactive: 'rgba(95, 240, 224, 0.55)', + navbarActiveBorder: 'rgba(255, 180, 84, 0.4)', + noteWrongBg: '#150a0d', + noteWrongBorder: 'rgba(255, 92, 114, 0.4)', + noteSavedBg: '#161006', + noteSavedBorder: 'rgba(255, 180, 84, 0.4)', + heat0: 'rgba(95, 240, 224, 0.08)', + heat1: 'rgba(95, 240, 224, 0.2)', + heat2: 'rgba(95, 240, 224, 0.4)', + heat3: 'rgba(95, 240, 224, 0.7)', + heat4: '#5ff0e0', + chartCount: '#ffb454', + chartAccuracy: '#5ff0e0', + heroXpIconBg: '#0d1116', +} as const; + +// RN 自訂字型要用 useFonts 載入後的 postscript 名稱,不能用 fontFamily+fontWeight 組合, +// 所以這裡直接對照網頁版每個 weight 個別取一個名字(400/500/700/800 mono、400/500/700/900 sans)。 +export const fonts = { + mono: { + regular: 'JetBrainsMono_400Regular', + medium: 'JetBrainsMono_500Medium', + bold: 'JetBrainsMono_700Bold', + extraBold: 'JetBrainsMono_800ExtraBold', + }, + sans: { + regular: 'NotoSansTC_400Regular', + medium: 'NotoSansTC_500Medium', + bold: 'NotoSansTC_700Bold', + black: 'NotoSansTC_900Black', + }, +} as const; + +// 網頁版 --radius 是 0px(全站直角),RN View 預設 borderRadius 就是 0, +// 這個常數只是給明確需要寫出來的地方參照用。 +export const radius = 0; +export const notch = 18; +export const notchSm = 12; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4d07625..5f0f150 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,6 +5,8 @@ "dependencies": { "@clerk/expo": "^3.7.4", "@easylearn/core": "*", + "@expo-google-fonts/jetbrains-mono": "^0.4.1", + "@expo-google-fonts/noto-sans-tc": "^0.4.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "5.2.0", "expo": "~57.0.4", @@ -13,6 +15,7 @@ "expo-dev-client": "~57.0.5", "expo-font": "~57.0.0", "expo-image-picker": "~57.0.2", + "expo-linear-gradient": "^57.0.0", "expo-linking": "~57.0.2", "expo-router": "~57.0.4", "expo-secure-store": "~57.0.0", diff --git a/apps/mobile/screens/ChapterMap.tsx b/apps/mobile/screens/ChapterMap.tsx index 748c4b5..2e473f7 100644 --- a/apps/mobile/screens/ChapterMap.tsx +++ b/apps/mobile/screens/ChapterMap.tsx @@ -2,8 +2,17 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import { colors, fonts } from '@/constants/theme'; import { chapters, type IconName, type Progress } from '@easylearn/core'; +type StatusIcon = 'lock' | 'check-circle' | 'play'; + +const STATUS_COLOR: Record = { + lock: colors.locked, + 'check-circle': colors.cyan, + play: colors.primary, +}; + interface ChapterMapProps { chapterId: string | null; progress: Progress; @@ -20,9 +29,9 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack } - + - + {chapter.title} @@ -30,15 +39,17 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack } const record = progress.completedLevels[level.id]; const prevDone = i === 0 || progress.completedLevels[chapter.levels[i - 1].id]; const locked = !prevDone; - const statusIcon: IconName = locked ? 'lock' : record ? 'check-circle' : 'play'; + const statusIcon: StatusIcon = locked ? 'lock' : record ? 'check-circle' : 'play'; return ( onStartLevel(level.id)} - style={[styles.levelRow, locked && styles.levelLocked, record && styles.levelDone]} + style={[styles.levelRow, locked && styles.levelLocked]} > - + + + {i + 1}. {level.title} @@ -64,37 +75,54 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', - gap: 10, - marginBottom: 6, + gap: 14, + marginBottom: 4, }, backBtn: { - padding: 4, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.25)', + width: 38, + height: 38, }, title: { - fontSize: 18, + fontFamily: fonts.sans.bold, + fontSize: 17, fontWeight: '700', + color: colors.inkStrong, }, levelRow: { flexDirection: 'row', alignItems: 'center', - gap: 10, - borderRadius: 12, - padding: 14, - backgroundColor: '#88889910', + gap: 14, + width: '100%', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + paddingVertical: 16, + paddingHorizontal: 18, }, levelLocked: { - opacity: 0.45, + opacity: 0.55, }, - levelDone: { - backgroundColor: '#2f9e4418', + levelIcon: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', }, levelName: { flex: 1, - fontSize: 14, - fontWeight: '600', + fontFamily: fonts.sans.bold, + fontSize: 15, + fontWeight: '700', + color: colors.ink, }, levelRecord: { - fontSize: 12, - opacity: 0.65, + fontFamily: fonts.mono.regular, + fontSize: 11, + color: colors.inkFaint, }, }); diff --git a/apps/mobile/screens/Home.tsx b/apps/mobile/screens/Home.tsx index 9459114..a19edd1 100644 --- a/apps/mobile/screens/Home.tsx +++ b/apps/mobile/screens/Home.tsx @@ -2,11 +2,16 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import { PrimaryButton, buttonTextStyles } from '@/components/Button'; +import { colors, fonts } from '@/constants/theme'; import { chapters, todayStr, yesterdayStr, type Progress } from '@easylearn/core'; const DAILY_GOAL = 20; const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']; +// 對照 index.css 的 chapter-list nth-child(1..3) 規則:三個章節依序 cyan/primary/wrong +const CHAPTER_ACCENTS = [colors.cyan, colors.primary, colors.wrong]; + const getWeekDates = (): string[] => { const now = new Date(); const day = now.getDay(); // 0=週日..6=週六 @@ -46,22 +51,30 @@ export default function Home({ progress, onOpenChapter, onMixedPractice }: HomeP 今日任務完成 {todayStats.total} 題 - + {streak} 天 - {weekDates.map((date, i) => ( - - {WEEKDAY_LABELS[i]} - - ))} + {weekDates.map((date, i) => { + const isDone = !!progress.dailyStats[date]; + const isToday = date === today; + return ( + + + {WEEKDAY_LABELS[i]} + + + ); + })} @@ -69,7 +82,7 @@ export default function Home({ progress, onOpenChapter, onMixedPractice }: HomeP 今日正確率 - + {todayAccuracy}% @@ -79,36 +92,37 @@ export default function Home({ progress, onOpenChapter, onMixedPractice }: HomeP 今日已做 - + {todayStats.total} 題 目標:{DAILY_GOAL} 題 - - - 隨機綜合練習(10 題) - + + + 隨機綜合練習(10 題) + 分科高效刷題 - {chapters.map((ch) => { + {chapters.map((ch, i) => { const done = ch.levels.filter((l) => progress.completedLevels[l.id]).length; const pct = ch.levels.length ? (done / ch.levels.length) * 100 : 0; + const accent = CHAPTER_ACCENTS[i] ?? colors.ink; return ( onOpenChapter(ch.id)}> - + {ch.title} 完成 {done} / {ch.levels.length} 關 - + - + ); })} @@ -123,9 +137,10 @@ const styles = StyleSheet.create({ gap: 16, }, streakCard: { - backgroundColor: '#2e78b722', - borderRadius: 16, - padding: 16, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(255, 180, 84, 0.35)', + padding: 20, gap: 10, }, streakTop: { @@ -133,8 +148,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', }, streakTopText: { + fontFamily: fonts.mono.regular, fontSize: 12, - opacity: 0.7, + color: 'rgba(95, 240, 224, 0.6)', + letterSpacing: 0.5, }, streakCount: { flexDirection: 'row', @@ -142,41 +159,60 @@ const styles = StyleSheet.create({ gap: 8, }, streakCountText: { - fontSize: 24, + fontFamily: fonts.mono.extraBold, + fontSize: 30, fontWeight: '800', + color: colors.primary, }, streakWeek: { flexDirection: 'row', + justifyContent: 'space-between', gap: 6, + marginTop: 6, }, streakDay: { flex: 1, alignItems: 'center', - paddingVertical: 6, - borderRadius: 8, - backgroundColor: '#88889915', - }, - streakDayToday: { + justifyContent: 'center', + height: 30, + backgroundColor: colors.track, borderWidth: 1, - borderColor: '#2e78b7', + borderColor: colors.optionBorder, }, streakDayDone: { - backgroundColor: '#2f9e4433', + backgroundColor: 'rgba(95, 240, 224, 0.25)', + borderColor: 'transparent', + }, + streakDayToday: { + backgroundColor: colors.primary, + borderColor: colors.primary, }, streakDayText: { + fontFamily: fonts.mono.regular, fontSize: 12, - fontWeight: '600', + color: 'rgba(95, 240, 224, 0.55)', + }, + streakDayTextDone: { + color: colors.cyan, + fontFamily: fonts.mono.bold, + fontWeight: '700', + }, + streakDayTextToday: { + color: '#241300', + fontFamily: fonts.mono.bold, + fontWeight: '700', }, statRow: { flexDirection: 'row', gap: 12, + marginTop: 14, }, statCard: { flex: 1, - borderRadius: 14, - padding: 14, - backgroundColor: '#88889910', - gap: 6, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + padding: 16, }, statCardHead: { flexDirection: 'row', @@ -184,66 +220,72 @@ const styles = StyleSheet.create({ alignItems: 'center', }, statCardHeadText: { - fontSize: 12, - opacity: 0.7, + fontFamily: fonts.mono.regular, + fontSize: 11, + letterSpacing: 0.5, + color: 'rgba(95, 240, 224, 0.55)', }, statCardValue: { - fontSize: 22, + fontFamily: fonts.mono.extraBold, + fontSize: 26, fontWeight: '800', + color: colors.cyan, + marginTop: 8, }, statCardHint: { - fontSize: 11, - opacity: 0.6, + fontFamily: fonts.sans.regular, + fontSize: 12, + color: colors.inkFaint, + marginTop: 4, }, mixedBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - backgroundColor: '#2e78b7', - borderRadius: 12, - paddingVertical: 14, - }, - mixedBtnText: { - color: '#fff', - fontWeight: '700', - fontSize: 15, + width: '100%', + marginTop: 4, }, sectionTitle: { - fontSize: 16, + fontFamily: fonts.mono.bold, + fontSize: 13, fontWeight: '700', + letterSpacing: 1.5, + color: 'rgba(95, 240, 224, 0.65)', + marginTop: 8, }, chapterList: { - gap: 10, + marginTop: -6, + gap: 12, }, chapterCard: { flexDirection: 'row', alignItems: 'center', - gap: 12, - borderRadius: 14, - padding: 14, - backgroundColor: '#88889910', + gap: 14, + width: '100%', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + padding: 16, }, chapterInfo: { flex: 1, - gap: 4, + gap: 2, }, chapterName: { + fontFamily: fonts.sans.bold, fontSize: 15, fontWeight: '700', + color: colors.ink, }, chapterProgress: { - fontSize: 12, - opacity: 0.65, + fontFamily: fonts.mono.regular, + fontSize: 11, + color: 'rgba(95, 240, 224, 0.4)', }, chapterBar: { - height: 5, - borderRadius: 3, - backgroundColor: '#88889925', + height: 6, + backgroundColor: colors.track, overflow: 'hidden', + marginTop: 4, }, chapterBarFill: { height: '100%', - backgroundColor: '#2f9e44', }, }); diff --git a/apps/mobile/screens/Notes.tsx b/apps/mobile/screens/Notes.tsx index 3cecbb3..a230282 100644 --- a/apps/mobile/screens/Notes.tsx +++ b/apps/mobile/screens/Notes.tsx @@ -2,6 +2,8 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import NotchedView from '@/components/NotchedView'; +import { colors, fonts, notch } from '@/constants/theme'; import { getWrongQuestions, type Progress } from '@easylearn/core'; interface NotesProps { @@ -19,40 +21,58 @@ export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onRe return ( - - - - 錯題本 - - 目前累積 {wrongCount} 題錯題 - { - e.stopPropagation(); - onReview(); - }} + + - 開始複習 - + + + 錯題本 + + 目前累積 {wrongCount} 題錯題 + { + e.stopPropagation(); + onReview(); + }} + > + 開始複習 + + - - - - 收藏題庫 - - 目前累積 {savedCount} 題收藏 - { - e.stopPropagation(); - onPracticeSaved(); - }} + + - {savedCount === 0 ? '無收藏題' : '開始練習'} - + + + 收藏題庫 + + 目前累積 {savedCount} 題收藏 + { + e.stopPropagation(); + onPracticeSaved(); + }} + > + {savedCount === 0 ? '無收藏題' : '開始練習'} + + ); @@ -60,51 +80,45 @@ export default function Notes({ progress, onOpenWrongBook, onOpenSavedBook, onRe const styles = StyleSheet.create({ container: { - padding: 16, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 40, gap: 16, }, card: { - borderRadius: 16, - padding: 20, - gap: 4, - borderWidth: 1, - }, - cardWrong: { - backgroundColor: '#e5484d14', - borderColor: '#e5484d40', - }, - cardSaved: { - backgroundColor: '#2e78b714', - borderColor: '#2e78b740', + padding: 22, }, cardHead: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 10, }, cardTitle: { + fontFamily: fonts.mono.bold, fontSize: 16, fontWeight: '700', }, cardCount: { + fontFamily: fonts.sans.regular, fontSize: 13, - opacity: 0.7, - marginTop: 6, - marginBottom: 14, + color: colors.inkSoft, + marginTop: 8, + marginBottom: 16, }, cardBtn: { alignSelf: 'flex-start', + backgroundColor: 'rgba(255, 255, 255, 0.05)', borderWidth: 1, - borderColor: '#88889940', - borderRadius: 10, - paddingVertical: 10, - paddingHorizontal: 20, + paddingVertical: 11, + paddingHorizontal: 22, }, cardBtnDisabled: { - opacity: 0.5, + opacity: 0.7, }, cardBtnText: { + fontFamily: fonts.mono.bold, fontSize: 13, fontWeight: '700', + color: colors.inkFaint, }, }); diff --git a/apps/mobile/screens/QuestionBook.tsx b/apps/mobile/screens/QuestionBook.tsx index 8d7a6e4..87fae12 100644 --- a/apps/mobile/screens/QuestionBook.tsx +++ b/apps/mobile/screens/QuestionBook.tsx @@ -2,7 +2,9 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import { SecondaryButton, buttonTextStyles } from '@/components/Button'; import QuestionReview from '@/screens/QuestionReview'; +import { colors, fonts } from '@/constants/theme'; import type { Question, WrongEntry } from '@easylearn/core'; interface QuestionBookProps { @@ -23,16 +25,16 @@ export default function QuestionBook({ kind, entries, savedIds, onToggleSave, on - + {title} {isWrong && entries.length > 0 && ( - - - 開始重練({entries.length} 題) - + + + 開始重練({entries.length} 題) + )} {entries.length === 0 ? ( @@ -59,40 +61,41 @@ export default function QuestionBook({ kind, entries, savedIds, onToggleSave, on const styles = StyleSheet.create({ container: { - padding: 16, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 40, gap: 10, }, header: { flexDirection: 'row', alignItems: 'center', - gap: 10, - marginBottom: 6, + gap: 14, + marginBottom: 8, }, backBtn: { - padding: 4, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.25)', + width: 38, + height: 38, }, title: { - fontSize: 18, + fontFamily: fonts.sans.bold, + fontSize: 17, fontWeight: '700', + color: colors.ink, }, reviewBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - borderWidth: 1, - borderColor: '#2e78b755', - borderRadius: 12, - paddingVertical: 13, + width: '100%', + maxWidth: 280, marginBottom: 4, }, - reviewBtnText: { - fontWeight: '700', - fontSize: 14, - }, empty: { textAlign: 'center', - opacity: 0.6, + fontFamily: fonts.sans.regular, + color: colors.inkSoft, fontSize: 14, marginTop: 40, }, diff --git a/apps/mobile/screens/QuestionReview.tsx b/apps/mobile/screens/QuestionReview.tsx index 575359d..cb46fd2 100644 --- a/apps/mobile/screens/QuestionReview.tsx +++ b/apps/mobile/screens/QuestionReview.tsx @@ -3,6 +3,8 @@ import { Linking, Pressable, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; import CodeBlock from '@/components/CodeBlock'; +import NotchedView from '@/components/NotchedView'; +import { colors, fonts, notch } from '@/constants/theme'; import { TYPE_META, type Question, type WrongEntryMeta } from '@easylearn/core'; interface QuestionReviewProps { @@ -17,17 +19,24 @@ export default function QuestionReview({ question, saved, onToggleSave, meta }: const typeMeta = TYPE_META[question.type]; return ( - + - + {typeMeta.label} {question.topic} - onToggleSave(question.id)} hitSlop={8} style={{ opacity: saved ? 1 : 0.35 }}> - + onToggleSave(question.id)} hitSlop={8} style={styles.saveBtn}> + @@ -42,123 +51,151 @@ export default function QuestionReview({ question, saved, onToggleSave, meta }: - {question.options.map((opt) => ( - - {opt.text} - - ))} + {question.options.map((opt) => { + const isAnswer = opt.id === question.answer; + return ( + + {opt.text} + + ); + })} - + 解釋 {question.explanation} {question.docs && ( - Linking.openURL(question.docs).catch(() => {})}> - 延伸閱讀:官方文件 + Linking.openURL(question.docs).catch(() => {})} style={styles.docsLink}> + + 延伸閱讀:官方文件 )} - + ); } const styles = StyleSheet.create({ card: { - gap: 4, - borderRadius: 14, - padding: 16, - backgroundColor: '#88889910', + padding: 22, }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, - marginBottom: 8, + marginBottom: 14, + flexWrap: 'wrap', }, typeBadge: { flexDirection: 'row', alignItems: 'center', - gap: 4, - backgroundColor: '#2e78b722', - borderRadius: 999, + gap: 6, + backgroundColor: colors.badgeBg, + borderWidth: 1, + borderColor: 'rgba(255, 180, 84, 0.4)', paddingHorizontal: 10, - paddingVertical: 4, + paddingVertical: 5, }, typeBadgeText: { - fontSize: 12, - fontWeight: '600', + fontFamily: fonts.mono.bold, + fontSize: 11, + fontWeight: '700', + color: colors.primary, }, topicLabel: { flex: 1, + fontFamily: fonts.sans.regular, fontSize: 12, - opacity: 0.6, + color: colors.inkSoft, + }, + saveBtn: { + marginLeft: 'auto', + padding: 4, }, reviewMetaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, - marginBottom: 6, + marginBottom: 8, + marginTop: -4, }, reviewMetaText: { + fontFamily: fonts.mono.regular, fontSize: 11, - opacity: 0.55, + color: colors.inkFaint, }, prompt: { + fontFamily: fonts.sans.bold, fontSize: 16, - fontWeight: '600', - lineHeight: 22, + fontWeight: '700', + color: colors.inkStrong, + lineHeight: 26, + marginBottom: 14, }, options: { - gap: 8, - marginTop: 6, + gap: 10, }, optionBtn: { + backgroundColor: colors.optionBg, borderWidth: 1, - borderRadius: 10, - paddingVertical: 12, - paddingHorizontal: 14, + borderColor: colors.optionBorder, + paddingVertical: 14, + paddingHorizontal: 16, }, optionCorrect: { - borderColor: '#2f9e44', - backgroundColor: '#2f9e4422', + backgroundColor: colors.correctFill, + borderColor: colors.correct, }, optionDimmed: { - borderColor: '#8888991a', - backgroundColor: '#88889908', - opacity: 0.5, + opacity: 0.4, }, optionText: { - fontSize: 14, + fontFamily: fonts.sans.regular, + fontSize: 14.5, + lineHeight: 21, + color: colors.ink, + }, + optionTextCorrect: { + color: colors.correct, + fontWeight: '700', }, feedback: { - marginTop: 12, - borderRadius: 10, - padding: 14, - gap: 8, - backgroundColor: '#2f9e4418', + marginTop: 16, + borderWidth: 1, + padding: 18, + backgroundColor: colors.correctSoft, + borderColor: 'rgba(95, 240, 224, 0.3)', }, feedbackTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, + marginBottom: 10, }, feedbackTitle: { - fontSize: 15, + fontFamily: fonts.mono.bold, fontWeight: '700', + fontSize: 14, + color: colors.correct, }, feedbackExplanation: { - fontSize: 14, - lineHeight: 20, + fontFamily: fonts.sans.regular, + fontSize: 13.5, + lineHeight: 23, + color: colors.inkSoft, + marginBottom: 12, }, docsLink: { - fontSize: 13, - color: '#2e78b7', - textDecorationLine: 'underline', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + docsLinkText: { + fontFamily: fonts.sans.regular, + fontSize: 12.5, + color: colors.cyan, }, }); diff --git a/apps/mobile/screens/Quiz.tsx b/apps/mobile/screens/Quiz.tsx index 9b50062..f1d1f9a 100644 --- a/apps/mobile/screens/Quiz.tsx +++ b/apps/mobile/screens/Quiz.tsx @@ -3,8 +3,11 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import Mascot from '@/components/Mascot'; import QuestionCard from '@/components/QuestionCard'; -import { getChapterIdForQuestion, type Level, type Progress } from '@easylearn/core'; +import { PrimaryButton, buttonTextStyles } from '@/components/Button'; +import { colors, fonts } from '@/constants/theme'; +import { getChapterIdForQuestion, getStage, type Level, type Progress } from '@easylearn/core'; const XP_CORRECT = 10; const XP_WRONG = 2; @@ -78,10 +81,13 @@ export default function Quiz({ const perfect = correctCount === questions.length; return ( - - {perfect && '🏆 '} - {isReview ? '重練完成!' : isMixed ? '練習完成!' : perfect ? '全對!太神了' : '關卡完成!'} - + + + {perfect && } + + {isReview ? '重練完成!' : isMixed ? '練習完成!' : perfect ? '全對!太神了' : '關卡完成!'} + + 答對題數 @@ -89,7 +95,7 @@ export default function Quiz({ {correctCount} / {questions.length} - + 獲得 XP +{finalXp} @@ -97,7 +103,7 @@ export default function Quiz({ {isReview && ( - + 畢業的錯題 {questions.filter((q) => !progress.wrongIds[q.id]).length} 題 @@ -108,9 +114,9 @@ export default function Quiz({ ? '答對一次就會從錯題本移除,還沒答對的之後可以再挑戰!' : '你的星球又長大了一點,明天也要回來澆灌它喔!'} - - {exitLabel} - + + {exitLabel} + ); } @@ -118,30 +124,28 @@ export default function Quiz({ return ( - - + + {questions.map((q, i) => ( - + ))} + {getStage(progress.xp).emoji} {index + 1}/{questions.length} {isReview && ( - + 錯題重練模式:答對一次就從錯題本移除,答錯會留到下次 )} {isMixed && ( - + 隨機綜合練習:跨章節抽題,不影響關卡進度 )} @@ -160,90 +164,134 @@ export default function Quiz({ const styles = StyleSheet.create({ container: { - padding: 16, - gap: 4, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 40, }, quizHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, - marginBottom: 8, + marginBottom: 16, + }, + backBtn: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.25)', + width: 38, + height: 38, }, dots: { flex: 1, flexDirection: 'row', - gap: 5, + flexWrap: 'wrap', + gap: 6, + justifyContent: 'center', }, dot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#88889930', + width: 9, + height: 9, + borderRadius: 4.5, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.3)', }, dotDone: { - backgroundColor: '#2f9e44', + backgroundColor: colors.primary, + borderColor: colors.primary, }, dotCurrent: { - backgroundColor: '#2e78b7', + width: 11, + height: 11, + borderRadius: 5.5, + marginTop: -1, + backgroundColor: colors.primary, + borderWidth: 2, + borderColor: colors.primary, + }, + quizPet: { + fontSize: 26, + lineHeight: 30, }, counter: { + fontFamily: fonts.mono.bold, fontSize: 12, - opacity: 0.65, + fontWeight: '700', + color: 'rgba(95, 240, 224, 0.6)', }, banner: { flexDirection: 'row', alignItems: 'center', - gap: 6, - backgroundColor: '#2e78b722', - borderRadius: 10, - padding: 10, - marginBottom: 8, + gap: 8, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(95, 240, 224, 0.15)', + paddingVertical: 12, + paddingHorizontal: 16, + marginBottom: 12, }, bannerText: { flex: 1, - fontSize: 12, + fontFamily: fonts.sans.regular, + fontSize: 13, + color: colors.inkSoft, }, resultContainer: { flexGrow: 1, - padding: 24, + paddingHorizontal: 24, + paddingTop: 32, + alignItems: 'center', + gap: 12, + }, + resultTitleRow: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 16, + gap: 8, }, resultTitle: { - fontSize: 22, - fontWeight: '800', + fontFamily: fonts.mono.bold, + fontSize: 20, + fontWeight: '700', + color: colors.inkStrong, }, resultStats: { + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, width: '100%', - gap: 10, + maxWidth: 320, + paddingHorizontal: 20, + paddingVertical: 8, + marginBottom: 4, }, statRow: { flexDirection: 'row', justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + }, + statRowDivider: { + borderTopWidth: 1, + borderTopColor: 'rgba(95, 240, 224, 0.12)', }, statLabel: { + fontFamily: fonts.sans.regular, fontSize: 14, - opacity: 0.7, + color: colors.inkSoft, }, statValue: { - fontSize: 14, - fontWeight: '700', + fontFamily: fonts.mono.regular, + fontSize: 15, + color: colors.cyan, }, resultHint: { - fontSize: 13, + fontFamily: fonts.sans.regular, + fontSize: 14, + color: colors.inkSoft, textAlign: 'center', - opacity: 0.7, - }, - primaryBtn: { - backgroundColor: '#2e78b7', - borderRadius: 12, - paddingVertical: 14, - paddingHorizontal: 32, - }, - primaryBtnText: { - color: '#fff', - fontWeight: '700', - fontSize: 15, + marginBottom: 12, }, }); diff --git a/apps/mobile/screens/Stats.tsx b/apps/mobile/screens/Stats.tsx index fef4c19..9610f65 100644 --- a/apps/mobile/screens/Stats.tsx +++ b/apps/mobile/screens/Stats.tsx @@ -1,8 +1,9 @@ -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Text } from '@/components/Themed'; import Icon from '@/components/Icon'; +import { colors, fonts } from '@/constants/theme'; import { chapters, todayStr, type Progress } from '@easylearn/core'; const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']; @@ -51,8 +52,8 @@ const activityLevel = (total: number): number => { return 4; }; -const HEAT_COLORS = ['#88889912', '#2e78b730', '#2e78b760', '#2e78b790', '#2e78b7']; - +// 對照 index.css 的 --heat-0..4(sequential 色階,經 dataviz skill 驗證過) +const HEAT_COLORS = [colors.heat0, colors.heat1, colors.heat2, colors.heat3, colors.heat4]; interface StatsProps { progress: Progress; @@ -64,6 +65,13 @@ export default function Stats({ progress }: StatsProps) { const dailyStats = progress.dailyStats ?? {}; const chapterStats = progress.chapterStats ?? {}; const heatmapScrollRef = useRef(null); + // 熱力圖要預設捲到最右邊(今天)。只用 onContentSizeChange 在某些情況下會搶在這個 + // 水平 ScrollView 自己的寬度(viewport)量到之前就先觸發,算出來的捲動位置不準—— + // 額外掛 onLayout 一起觸發同一個函式,兩邊哪個晚到就以哪個為準,同時包一層 + // requestAnimationFrame 讓 scrollToEnd 儘量等到這一輪版面真的 commit 完再執行。 + const scrollHeatmapToLatest = useCallback(() => { + requestAnimationFrame(() => heatmapScrollRef.current?.scrollToEnd({ animated: false })); + }, []); const totals = Object.values(dailyStats).reduce( (acc, d) => ({ total: acc.total + d.total, correct: acc.correct + d.correct }), @@ -102,10 +110,10 @@ export default function Stats({ progress }: StatsProps) { - 累計答對題數 + 累計答對題數 - - {totals.correct} 題 + + {totals.correct} 題 @@ -124,7 +132,8 @@ export default function Stats({ progress }: StatsProps) { horizontal showsHorizontalScrollIndicator={false} ref={heatmapScrollRef} - onContentSizeChange={() => heatmapScrollRef.current?.scrollToEnd({ animated: false })} + onContentSizeChange={scrollHeatmapToLatest} + onLayout={scrollHeatmapToLatest} > @@ -169,7 +178,7 @@ export default function Stats({ progress }: StatsProps) { - + 做題量 @@ -179,7 +188,7 @@ export default function Stats({ progress }: StatsProps) { {d.label} @@ -189,7 +198,7 @@ export default function Stats({ progress }: StatsProps) { - + 正確率 @@ -200,7 +209,7 @@ export default function Stats({ progress }: StatsProps) { style={[ styles.miniBar, { - backgroundColor: '#2f9e44', + backgroundColor: colors.chartAccuracy, height: d.total > 0 ? Math.max(4, (d.accuracy / 100) * 72) : 2, }, ]} @@ -242,7 +251,9 @@ export default function Stats({ progress }: StatsProps) { const styles = StyleSheet.create({ container: { - padding: 16, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 40, gap: 12, }, tileRow: { @@ -251,16 +262,21 @@ const styles = StyleSheet.create({ }, tile: { flex: 1, - borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, padding: 16, - backgroundColor: '#88889910', gap: 8, }, tileWide: { - borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: 'rgba(255, 180, 84, 0.25)', padding: 16, - backgroundColor: '#2e78b714', - gap: 8, + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }, tileWideValueRow: { flexDirection: 'row', @@ -268,29 +284,49 @@ const styles = StyleSheet.create({ gap: 6, }, tileLabel: { + fontFamily: fonts.mono.regular, + fontSize: 11, + // 一次性淡化標籤用的透明度,跟 colors 裡既有的 cyan/primary 系列 token 沒有對應值 + color: 'rgba(95, 240, 224, 0.55)', + }, + tileWideLabel: { + fontFamily: fonts.mono.regular, fontSize: 11, - opacity: 0.6, + color: 'rgba(255, 180, 84, 0.6)', }, tileValue: { + fontFamily: fonts.mono.extraBold, fontSize: 24, fontWeight: '800', + color: colors.cyan, + }, + tileWideValue: { + fontFamily: fonts.mono.extraBold, + fontSize: 22, + fontWeight: '800', + color: colors.primary, }, sectionTitle: { + fontFamily: fonts.mono.bold, fontSize: 13, fontWeight: '700', - opacity: 0.6, - marginTop: 10, + letterSpacing: 1.5, + // 一次性淡化標籤用的透明度,跟 colors 裡既有的 cyan 系列 token 沒有對應值 + color: 'rgba(95, 240, 224, 0.65)', + marginTop: 12, }, sectionHint: { - fontSize: 11, - opacity: 0.5, + fontFamily: fonts.sans.regular, + fontSize: 12, + lineHeight: 19, + color: colors.inkFaint, marginTop: -4, - lineHeight: 16, }, heatmapCard: { - borderRadius: 14, - padding: 14, - backgroundColor: '#88889910', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + padding: 16, }, heatmapRow: { flexDirection: 'row', @@ -305,8 +341,9 @@ const styles = StyleSheet.create({ heatmapWeekday: { height: 11, lineHeight: 11, + fontFamily: fonts.mono.regular, fontSize: 9, - opacity: 0.55, + color: colors.inkFaint, }, heatmapMonths: { flexDirection: 'row', @@ -319,8 +356,9 @@ const styles = StyleSheet.create({ // (超出 ScrollView 量到的內容寬度那段一樣會被裁掉,尤其是捲到最右邊、沒有下一欄可以 // 借用空間的最後一個月份),所以改成單純夠寬的固定寬度,不依賴溢出。 width: 16, + fontFamily: fonts.mono.regular, fontSize: 9, - opacity: 0.55, + color: colors.inkFaint, }, heatmapGrid: { flexDirection: 'row', @@ -334,7 +372,6 @@ const styles = StyleSheet.create({ heatmapCell: { width: 11, height: 11, - borderRadius: 2, }, heatmapCellBlank: { width: 11, @@ -348,15 +385,19 @@ const styles = StyleSheet.create({ marginTop: 10, }, heatmapLegendText: { + fontFamily: fonts.mono.regular, fontSize: 10, - opacity: 0.55, + color: colors.inkFaint, }, miniChartPair: { flexDirection: 'row', gap: 16, - borderRadius: 14, - padding: 16, - backgroundColor: '#88889910', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, + paddingTop: 18, + paddingHorizontal: 16, + paddingBottom: 8, }, miniChart: { flex: 1, @@ -368,13 +409,13 @@ const styles = StyleSheet.create({ marginBottom: 10, }, miniChartTitle: { - fontSize: 12, - opacity: 0.7, + fontFamily: fonts.sans.regular, + fontSize: 13, + color: colors.inkSoft, }, legendDot: { width: 8, height: 8, - borderRadius: 2, }, miniChartBars: { flexDirection: 'row', @@ -390,31 +431,34 @@ const styles = StyleSheet.create({ height: '100%', }, miniBarValue: { + fontFamily: fonts.mono.regular, fontSize: 10, - opacity: 0.5, + color: colors.inkFaint, height: 14, }, miniBar: { width: '60%', minWidth: 6, - borderRadius: 2, }, miniBarLabel: { + fontFamily: fonts.mono.regular, fontSize: 10, - opacity: 0.5, + color: colors.inkFaint, marginTop: 6, }, miniBarLabelToday: { - opacity: 1, + color: colors.primary, + fontFamily: fonts.mono.bold, fontWeight: '700', }, chapterList: { gap: 12, }, chapterCard: { - borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.optionBorder, padding: 16, - backgroundColor: '#88889910', gap: 8, }, chapterCardHead: { @@ -422,25 +466,29 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', }, chapterCardTitle: { + fontFamily: fonts.sans.regular, fontSize: 14, fontWeight: '700', + color: colors.ink, }, chapterCardPct: { + fontFamily: fonts.mono.bold, fontSize: 14, fontWeight: '700', + color: colors.cyan, }, chapterBar: { height: 6, - borderRadius: 3, - backgroundColor: '#88889925', + backgroundColor: colors.track, overflow: 'hidden', }, chapterBarFill: { height: '100%', - backgroundColor: '#2f9e44', + backgroundColor: colors.cyan, }, chapterCardSub: { - fontSize: 11, - opacity: 0.55, + fontFamily: fonts.mono.regular, + fontSize: 10, + color: colors.inkFaint, }, }); diff --git a/docs/rn-migration.md b/docs/rn-migration.md index 710d6da..ac272a7 100644 --- a/docs/rn-migration.md +++ b/docs/rn-migration.md @@ -298,6 +298,243 @@ 拖曳/縮放、改名、放大鏡移除、成長史彈窗、輸入框文字顏色,全部驗證過沒問題。 **Phase 6 到此真正完成**,已 commit 並 push 到 `origin/dev`。 - [ ] **Phase 7**(視是否加 OAuth 才需要):Clerk native SSO redirect 的 Dashboard 設定。 +- [ ] **UI 全面對齊 apps/web 窄螢幕樣式**(2026-07-13 實作完成,**完全沒有原生重建/真機驗證過, + 風險最高,需要先重新原生建置才能測**) + - 使用者要求「RN 專案的 UI 對齊網頁的窄螢幕樣式,要完全一模一樣」,範圍是全部畫面 + (Home/ChapterMap/Quiz/Notes/QuestionBook/Stats/Profile),並確認要做到「含缺角與 + 自訂字型的像素級還原」(AskUserQuestion 問過,不是我自己假設的範圍)。 + - 新增 `constants/theme.ts`:把 `apps/web/src/index.css` 的 `:root` CSS variables(顏色/ + notch 尺寸)整套搬進 RN 常數,之後任何畫面要用顏色都從這裡引用,不再各自硬編十六進位色碼。 + - 新增 `components/NotchedView.tsx`:RN 沒有原生 `clip-path`,用 `react-native-svg` 的 + `Polygon` 畫一個缺角多邊形當背景(`onLayout` 量到實際寬高才畫,第一幀會有一瞬間無背景, + 是可接受的 trade-off),取代網頁版 `.question-card`/`.note-card`/`.login-box`(notch=18, + 切右上/左下)與 `.primary-btn`(notch-sm=12,切左上/右下)的 clip-path 缺角造型。 + - 新增 `components/Button.tsx`(PrimaryButton/SecondaryButton/TextButton)、 + `components/XpBar.tsx`(cyan→primary 漸層進度條,用新裝的 `expo-linear-gradient`)、 + `components/TabBarButton.tsx`(讓底部 tab bar 選中分頁露出 2px primary 色細線, + 對照 index.css `.navbar-tab.is-active` 在 `@media(max-width:640px)` 的樣式)。 + - **新增字型**:裝 `@expo-google-fonts/jetbrains-mono`/`@expo-google-fonts/noto-sans-tc` + (跟 `apps/web/src/app/layout.tsx` 從 Google Fonts 載入的同一組 weight:JetBrains Mono + 400/500/700/800、Noto Sans TC 400/500/700/900),`app/_layout.tsx` 的 `useFonts` 換成載入 + 這 8 個字重,取代原本沒被用到的 Expo 模板預設字型 `SpaceMono`。畫面裡凡是網頁版 + `font-family:var(--font-mono)`/`var(--font-sans)` 的地方都改引用 `theme.ts` 對應的 + `fonts.mono.*`/`fonts.sans.*` weight-specific family 名稱(RN 自訂字型不能用 + `fontFamily+fontWeight` 組合模擬粗細,要載入時就分開每個字重)。 + - `constants/Colors.ts`/`components/Themed.tsx`:`dark` 主題改成直接對照 `theme.ts` 的 + `colors.bg`/`colors.ink`(原本是 Expo 模板預設的 `#121212`/`#e6e6e6`,跟網頁版的星際深色 + 主題完全不同色),`Text` 元件預設套上 `fonts.sans.regular`(對照網頁版 `body{font-family: + var(--font-sans)}`)。 + - 逐畫面把 hard-coded 的通用色碼(`#2e78b7`/`#88889910` 這類 Expo 模板殘留色)換成 + `theme.ts` 的網頁版色票,並把所有 `borderRadius` 拿掉(網頁版 `--radius:0`,RN View 預設 + `borderRadius` 本來就是 0,不用特別寫)。過程中也順手修正了幾個現有程式碼跟網頁版行為 + 不一致的地方(都是照網頁版 index.css/JSX 逐一核對抓出來的): + - `ChapterMap.tsx` 的 `levelDone`(完成關卡的綠色底):網頁版 `.level-done` 這個 + class 在 index.css 裡其實沒有對應規則(沒視覺效果),只有 `:disabled` 的鎖定關卡才變半透明, + mobile 原本畫的綠色底是多出來的,已拿掉。 + - `Home.tsx` 的今日日期 pill:`is-today` 應該蓋掉 `is-done` 的底色(CSS source order + `.is-today` 排在 `.is-done` 後面),原本 mobile 的 style array 順序相反,已對調。 + - `QuestionCard.tsx`/`QuestionReview.tsx` 的 `is-dimmed` 選項透明度:網頁版是 `0.4`, + mobile 原本寫 `0.5`。 + - `QuestionCard.tsx`/`QuestionReview.tsx`/`Quiz.tsx` 補齊網頁版有但 mobile 缺的幾個 + 視覺元素:收藏星號改用 lucide `fill` prop 真的填滿(不是只變色)、feedback 標題列補上 + 對應圖示、「延伸閱讀」連結補上 book-open 圖示、下一題按鈕補上 flag/arrow-right 圖示、 + Quiz 結算畫面補上 `Mascot`/全對時的獎盃圖示、答題頁 header 補上會依照 XP 換階段表情的 + 吉祥物 emoji(`quiz-pet`,先前完全沒渲染這個元素)。 + - `Profile.tsx` 的「學習統計」四格:網頁版在 `@media(max-width:480px)`(幾乎所有手機寬度 + 都會落在這個斷點內)是單欄橫列(標籤在左、數字在右),不是桌面版的 4 欄 grid, + mobile 原本用的是 2 欄 wrap grid,已改成對照手機斷點的單欄橫列樣式。 + - `Stats.tsx` 的分科成效長條顏色:mobile 原本用綠色,網頁版 `.chapter-bar-fill` 基底規則 + 是 cyan(Stats 頁沒有套用 Home 頁那組 `.chapter-list:nth-child` 的 cyan/primary/wrong + 三色 override,因為那組選擇器只認 `.chapter-list` 這個 class,Stats 用的是 + `.chapter-stat-list`),已改回 cyan。 + - **成長史彈窗(使用者這次特別交代的部分)**:因為 mobile 已經在 Phase 6 把成長史從「卡片內 + 展開清單」改成 `Modal` 彈窗(網頁版目前仍是原地展開,兩邊介面結構不同,沒有網頁版樣式可以 + 直接照抄),這次是「依照整體設計系統風格新增」:`modalCard` 背景改成 `--card` 深色卡片色 + (不是原本模板殘留的 `#121212`)、拿掉原本的圓角(`borderTopLeftRadius/RightRadius:20`, + 改成跟全站一致的直角 `radius:0`,只保留一條 cyan 邊框分隔線)、標題與清單項目字體/顏色 + 全部改用 `theme.ts` 的 mono/sans 字型與色票,維持跟其他卡片一致的視覺語言。 + - **刻意維持、沒有還原成網頁版的兩處差異**(都是先前對話使用者當面要求拿掉的,不是這次漏做): + (1) 個人資料不顯示 `USER.XXXXXXXX` 那行(網頁版 `AccountHeader.tsx` 還留著這行,mobile + 在 Phase 6 已經拿掉,這次沒有加回來);(2) 頭像縮放拉桿左側沒有放大鏡圖示(網頁版 + `avatar-zoom` 還有 `search` icon,mobile 在 Phase 6 已經拿掉,這次也沒加回來)。 + - **這個環境能驗證的都驗證了**:`packages/core`/`apps/web`/`apps/mobile` 三個 + `tsc --noEmit` 過、`apps/mobile` 的 `expo export --platform web` 成功(1601 modules, + 比裝字型/gradient 前的 1516 略增,符合預期)、`expo-doctor` 19/20(唯一沒過的仍是既有的 + metro monorepo 設定,跟這次改動無關)。 + - **完全沒有測過、風險最高的部分(使用者要自己在真機/模擬器驗證才能定案)**: + 1. 這次新增了 `expo-linear-gradient`(xp 進度條漸層)+兩套 Google Fonts 套件, + 都是原生層級的新依賴,**必須重新 `npx expo run:ios`/`run:android` 完整原生建置**, + 光重啟 `expo start` 不會生效(跟 Phase 2/6 提醒過的規則一致)。 + 2. 整體視覺是否真的跟網頁版窄螢幕「一模一樣」——這次是照著 index.css 的數值逐一手動換算成 + RN style,沒有工具能自動比對兩邊的實際渲染像素,需要使用者自己並排網頁版(縮小瀏覽器 + 視窗到手機寬度)跟 app 截圖比對。 + 3. 缺角卡片(`NotchedView`)用 SVG 畫背景,`onLayout` 量到尺寸前的那一瞬間沒有背景色, + 正常情況下應該快到看不出來,但沒有在真機上實際感受過這個時間差。 + 4. 成長史彈窗、頭像編輯、答題流程等既有功能這次只有動樣式沒有動邏輯,理論上功能行為不受 + 影響,但沒有重新整輪測過。 + - 尚未 commit,等使用者原生重建後測過視覺/功能都沒問題才能 commit。 + - **2026-07-13 追加,使用者實機測試回報兩個問題**: + 1. Android 點個人資料 tab 直接丟 `IllegalViewOperationException: Can't find ViewManager + 'ViewManagerAdapter_ExpoLinearGradient'`——確認就是原生依賴沒重建,`expo start` 不會 + 重新連結新裝的 `expo-linear-gradient`,需要 `npx expo run:android`。 + 2. iOS 模擬器上「成長史」卡片的 XP 進度條顯示一塊「Unimplement...」的灰色佔位框——同一個 + 根因,iOS 跟 Android 是各自獨立的原生專案,Android 重建過不代表 iOS 也連結了,需要 + 另外 `npx expo run:ios`。 + 3. (同一輪回報)近半年學習熱力圖沒有預設捲到今天,要手動往右滑才看得到——這是 Phase 5 + 就存在、從來沒被使用者實測過的舊問題,不是這次改動造成的。原本只掛 + `onContentSizeChange` 觸發 `scrollToEnd`,在 iOS 上有時會搶在這個水平 ScrollView + 自己的 viewport 寬度量到之前就先觸發,算出來的捲動位置不準。修法:額外掛 `onLayout` + 也觸發同一個函式,並包一層 `requestAnimationFrame` 讓捲動盡量等這輪版面真的 + commit 完再執行(`screens/Stats.tsx`)。**這個修法沒有模擬器可以實測,只是邏輯上的 + 強化,需要使用者重建後確認。** + - **2026-07-13 再追加,使用者回報成長史彈窗底部有奇怪的黑色背景(第一輪判斷錯誤,見下)**: + 第一輪猜測根因是 RN 核心 `` 在 Android 上另開系統 `Dialog` 視窗、不繼承 app 主視窗 + 的 edge-to-edge/安全區設定,因此整個拿掉 ``,改成同一棵 React tree 裡的絕對定位 + 覆蓋層(`position:'absolute'`,順手把「點背景關閉」也補上)。**使用者重建後回報黑底依舊 + 存在**——證明 Modal 完全不是根因(拿掉之後問題還在)。 + - **真正根因**:``(包住 `` 清單)沒有給 `flexShrink`, + 放在只有 `maxHeight`(沒有固定 `height`)的父層 `modalCard` 裡時,Yoga 預設不會讓它 + 乖乖依內容縮小,而是撐開去填滿 `maxHeight` 的上限——清單實際內容(12 個階段,遠不到 + `maxHeight:75%` 的高度)結束後,卡片下半部留了一大塊空的 `colors.card`(`#0a1216`, + 深色主題下跟純黑幾乎無法用肉眼分辨)背景,看起來就像「一塊怪異的黑色」。 + - **修法**:`ScrollView` 補上 `style={{flexShrink:1}}`(讓它可以縮到比內容還小,這是這個 + RN pattern——「maxHeight 容器裡包一個高度不固定、內容短時要貼合內容的 ScrollView」—— + 的標準解法),並把原本掛在 `modalCard` 外層的 `paddingBottom: insets.bottom+16` + 移進 `ScrollView` 的 `contentContainerStyle`,讓安全區留白緊貼在清單最後一項後面、 + 而不是卡片外層一塊獨立算出來、跟清單實際高度脫勾的空間。 + - **教訓**:這類「RN 容器留白/黑塊」的視覺 bug,先檢查是不是 `ScrollView`/可捲動容器 + 在 `maxHeight`-限制的父層裡沒設 `flexShrink` 撐開了不該有的空間,比先假設是原生視窗/ + 安全區問題更該優先排查——後者(Modal 分離視窗)雖然是真實存在的平台限制,但這次並不是 + 這個 bug 的成因,**拿掉 `` 這個改動本身沒有錯(仍然是比較穩妥的架構,順手修好 + 的「點背景關閉」也是真的改善),只是它沒有解到使用者回報的這個黑底問題,之後如果 + 再遇到全螢幕覆蓋層的怪異留白,直接先看 ScrollView 有沒有 flexShrink,不用重新走一次 + Modal 分離視窗的排查路徑。** + - 同樣沒有模擬器可以實測,需要使用者重建後(這次是純 JS 邏輯改動,理論上不需要重新 + 原生建置,reload 就會生效)確認黑底真的消失。 + - **2026-07-13 第三輪,使用者回報 flexShrink 那次修完問題依舊,且不接受再猜——這次改用 + RN 內建 Element Inspector(`Cmd+D` → Show Element Inspector)直接點選問題區塊, + 才抓到真正根因**: + - Inspector 顯示被點到的元素(`modalHeader`/`heroXpRow` 這類純版面用的 row 容器) + 實際 `backgroundColor` 是 `#04070a`,但這兩個 style 物件根本沒有寫 + `backgroundColor` 這個屬性——代表這個顏色不是我寫的樣式套上去的,是別的地方硬塗上去的。 + - 查出來是 `app/(tabs)/profile.tsx` 這支檔案的 `import { Text, View } from + '@/components/Themed'`:`components/Themed.tsx` 的 `View` 元件只要呼叫端沒有明確給 + `backgroundColor`,就會**強制**塗上整頁背景色 `colors.bg`(`#04070a`)。這個顏色跟卡片色 + `colors.card`(`#0a1216`)非常接近、單獨看幾乎分不出來,但當一個純版面用的 row/column + 容器(沒特別設背景)疊在卡片內部時,就會用 `#04070a` 蓋掉卡片本來的 `#0a1216`, + 在有邊界的地方(例如這個 row 剛好佔滿卡片下半部)看起來就是「一塊比周圍明顯偏黑的 + 區域」——這就是使用者一路回報的「黑色背景」,跟 Modal、ScrollView flexShrink、 + insets 都無關,前兩輪修法方向完全錯了。 + - **檢查範圍**:`grep` 過整個 `apps/mobile`,這次改版所有畫面裡,只有 + `app/(tabs)/profile.tsx` 跟沒動過的 `app/+not-found.tsx`/`app/sso-callback.tsx` + 會從 `@/components/Themed` 匯入 `View`;其餘畫面(Home/ChapterMap/Quiz/ + QuestionCard/QuestionReview/Notes/QuestionBook/Stats/AccountHeader/Mascot/ + GrowthHistory)都只匯入 `Text`,版面用的 `View` 本來就是從 `react-native` 直接拿 + (預設透明),所以這個 bug**只出現在 profile.tsx 這一支檔案**,不是全站性的。 + - **修法**:`profile.tsx` 的 `View` 改成從 `react-native` 直接匯入(不再用 Themed 版), + `Text` 仍保留 Themed 版本(`Text` 只影響文字顏色,不會有「蓋住底下內容」的問題, + 不受這個 bug 影響)。原本仰賴 Themed View 預設塗色的 `container` style(登入中的 + loading 畫面用)額外補上明確的 `backgroundColor: colors.bg`,避免拿掉預設塗色後 + 這個情境變透明。 + - **教訓**:這類「顏色看起來對不上」的 bug,之後優先用 RN 內建 Element Inspector 或 + React DevTools 直接點出實際套用的 style 值,不要只憑程式碼推論或猜測平台限制 + (Modal 分離視窗、ScrollView maxHeight 那兩輪都是合理但錯誤的推論,实測工具一次就 + 定位到真正原因)。也提醒:`Themed.tsx` 的 `View` 元件「沒寫 backgroundColor 就強制 + 塗上整頁背景色」這個設計,只適合拿來當「畫面最外層的頁面背景容器」用,**不要拿它 + 當純版面用的 row/column 容器**(那種情境要用 `react-native` 原生的 `View`), + 以後這個專案或其他用同一套 Expo 模板起家的專案都要注意這個界線。 + - 這次已經在使用者的 Element Inspector 截圖上直接對照原始碼確認 `#04070a` 對應 + 沒設 `backgroundColor` 的 `heroXpRow`/`modalHeader`,可信度比前兩輪高,但**修完 + 這版還是沒有經過使用者重新整理後的實機確認,不能標記完成**。 + - **使用者測完回報:黑底問題確實解決了**,同時新發現「捲動畫面時內容會跟頂部系統 + 狀態列(時間/電量)重疊」。根因是 `profile.tsx` 原本把 `insets.top` 的安全區留白 + 直接寫進 `ScrollView` 的 `contentContainerStyle`(屬於可捲動內容的一部分),往上捲 + 一下這塊留白就跟著捲走,底下內容接著就畫到狀態列後面去了。其他三個 tab(Home/Notes/ + Stats)本來就沒有這個問題,因為它們是在 `app/(tabs)/index.tsx`/`notes.tsx`/`stats.tsx` + 外層包一個**不會捲動**的 `View`(自己的 `paddingTop:insets.top`,不屬於任何 ScrollView + 的內容),只有 `profile.tsx` 這支是把整個畫面自己組出來、沒有套用這個既有的固定安全區 + 包裝模式。**修法**:把 `insets.top` 從 `contentContainerStyle` 移到外層新增的 + `screenWrap`(`flex:1, backgroundColor: colors.bg`)自己的 `paddingTop`,讓這塊 + 留白變成固定不捲動的區域,跟其他三個 tab 的作法一致。**這輪修法尚未經使用者驗證。** + - **2026-07-13 又追加,使用者回報 iOS 模擬器成長史彈窗滑不到最後 3 個階段(超新星/星雲/ + 黑洞),只看得到前 9 個;Android 捲動正常**:確認是(先問過使用者「是完全看不到多滑的 + 內容、還是內容都在只是滑不動」釐清屬於後者,不是「本來就沒有更多內容」的誤判) + 上一輪加的 `ScrollView` `flexShrink:1` 在 iOS/Android 上算出來的實際可捲動框架不一致—— + Android 正確、iOS 疑似把可捲動框架算得比視覺內容還小(內容因為 `overflow:visible` 還是 + 看得到,但手指划的範圍在框架外,滑不動)。**修法**:改用 `flex:1`(不是 `flexShrink:1`), + 讓 Yoga 直接用「`modalCard` 扣掉 header 後的剩餘空間」決定 ScrollView 框架大小,不透過 + 內容自身高度做縮放計算。這個改法之所以安全:`GrowthHistory` 固定顯示全部 12 個階段, + 內容高度不會因進度而變短,不用擔心 `flex:1` 在內容偏短時把卡片撐到 `maxHeight` 上限 + 留空白(那個「撐開留空白」的擔心本來就是上一輪的假設,這次的黑底 bug 已經證實是 + Themed View 蓋色造成的,跟 flexShrink/flex 的選擇無關,所以換掉不會有回歸風險)。 + **這輪修法尚未經使用者驗證。** + - **使用者測完回報:改用 `flex:1` 之後兩個平台都壞了,連內容都看不到**(iOS/Android + 皆是),比原本只有 iOS 滑不到最後幾項還更糟。根因查出來:`modalCard` 自己沒有固定 + `height`(只有 `maxHeight` 上限),`ScrollView` 給 `flexGrow:1`(`flex:1` 隱含)等於 + 要求「長到填滿父層可用空間」,但父層自己的高度是靠內容反推的(`flexBasis:auto` 加 + `maxHeight` 上限),這種「子層要長滿、父層要靠子層決定大小」互相依賴的情況 Yoga + 解不出明確答案,兩邊乾脆都塌縮成 0——這是這幾輪裡我學到最扎實的一條:**`flexGrow`/ + `flex:1` 只能用在父層有明確(不是靠內容反推、不是只有 maxHeight 上限)高度的情境**, + 這次的父層不符合這個前提,是我沒有先確認就套用的錯誤。 + - **改法(第三次嘗試,這次換一個完全不同、不依賴 flex 協商的策略)**:不再讓 ScrollView + 的高度依賴跟父層的 flex 協商,改用 `useWindowDimensions()` 量出實際螢幕高度,算出一個 + **明確的數字** `growthModalMaxHeight = windowHeight * 0.75 - 80`(80 是標題列+卡片 + padding 的估計扣除量),直接當 `` 的 `maxHeight`。 + 這是 ScrollView 最基礎、最不會出錯的標準用法——內容超過這個明確數字就自動可捲動, + 不需要 Yoga 去解任何父子互相依賴的 flex 協商,兩個平台算出來的結果理論上會一致。 + `modalCard` 自己的 `maxHeight:'75%'` 保留(當一個無害的外層保險上限,反正 ScrollView + 自己的數字上限已經比它小,實際生效的是 ScrollView 這層)。 + - **這輪修法尚未經使用者驗證,且是同一個子問題(成長史彈窗捲動)第三次嘗試修**, + 如果這次還是不行,下次不要再用 flex 相關屬性猜,直接考慮把整個彈窗改成用 + `FlatList`/固定像素高度的簡單方案,或請使用者用 React DevTools 直接量測 + `ScrollView` 實際 render 出來的 frame/contentSize 數字。 + - **使用者測完回報:加上 onLayout/onContentSizeChange log 量出來的數字完全正常** + (`modalCard` 653、ScrollView 可視區域 575.3、內容高度 760,內容確實比可視區域高 + 約 185px),**但滑動依舊沒反應**——這組數字第一次證實版面尺寸從頭到尾都沒問題, + 問題出在別的地方。 + - **第四輪,終於抓到真正根因**:`modalCard` 外層包了一個 `Pressable` + (`onPress={(e) => e.stopPropagation()}`,用來擋「點卡片內部不要連動關閉背景遮罩」), + 這個 `Pressable` **直接包住了 ScrollView**——Touchable/Pressable 包住 ScrollView 在 + iOS 上是已知會搶走捲動手勢的問題類別(Pressable 為了顯示按壓回饋,一碰到就搶著宣告 + 自己是 responder,導致 ScrollView 原生的 pan 手勢辨識器拿不到那個觸控)。**修法**: + `modalCard` 改用 `View`(拿掉 `Pressable`/`stopPropagation`),代價是「點卡片內部 + 空白處會誤觸關閉彈窗」這個保護跟著沒了——這是刻意接受的取捨,捲不動的清單是更嚴重的 + 問題。**這輪修法尚未經使用者驗證,是同一個子問題第四次嘗試**。 + - **教訓**:這次的排查順序值得記下來——(1) 先懷疑版面尺寸算錯(flexShrink/flex 那兩輪, + 都錯),(2) 用量測數字排除版面尺寸這個可能性,(3) 數字排除之後才轉向手勢/觸控衝突 + 這個完全不同類別的原因,一次就找對。**同一個症狀(滑不動/內容被截斷)不代表原因只有 + 一種,量測數字能幫忙排除掉一整類假設,不用每次都重新從頭猜。** + - **第五輪,使用者回報拿掉 modalCard 的 Pressable 之後更糟**:現在點卡片內容會直接觸發 + 關閉彈窗,而且還是完全滑不動。根因:拿掉 modalCard 自己的 Pressable 之後,整棵卡片 + (含 ScrollView)都還是包在 `modalBackdrop` 這個外層 `Pressable` 底下——問題本來就不是 + 「哪一層 Pressable」,而是「任何 Pressable 只要是 ScrollView 的祖先節點,就會搶走 + iOS 的捲動手勢」,加上少了 modalCard 自己的 stopPropagation,現在連點擊都直接冒泡到 + 最外層的 `modalBackdrop.onPress` 觸發關閉。 + - **正確架構(這次徹底改結構,不再只是調參數)**:背景(點擊關閉)跟卡片本體改成 + **手足關係**,不是卡片包在背景 Pressable 裡面: + ```tsx + {/* 純容器,不接觸控 */} + {/* 鋪滿全螢幕,負責變暗+點擊關閉 */} + {/* 鋪滿全螢幕、flex-end 對齊, + 自己不接觸控只負責排版 */} + ...header/ScrollView... + + + ``` + RN 的觸控命中測試是照畫面上實際疊放的視覺樹(不是純粹的 React 元件父子關係)由上而下 + 找最上層的視圖決定要交給誰處理:點在卡片本體範圍內,會直接命中卡片自己的畫面元素 + (因為卡片視覺上疊在背景「上面」,即使兩者在 React tree 裡是手足不是父子),不會經過 + `modalBackdrop` 的 `Pressable`;只有點在卡片以外、真正露出背景的空白處,才會落到 + `modalBackdrop` 上觸發關閉。`modalCardWrap` 用 `pointerEvents="box-none"`:這層本身 + 鋪滿全螢幕只是為了用 `justifyContent:'flex-end'` 排版卡片位置,`box-none` 讓它自己 + 不吃觸控(沒被卡片佔到的空白部分的觸控會穿過它,落到底下的 `modalBackdrop`),只有 + 它的子節點(卡片本體)會正常接收觸控。這樣 ScrollView 的祖先鏈裡完全沒有任何 + Pressable/Touchable,捲動手勢不會被搶。 + - **這是同一個子問題第五次修,這次是結構性重寫、不是調參數。使用者已在 iOS 模擬器 + 實測確認:黑底、狀態列重疊、無法捲動、誤觸關閉四個問題全部解決。** 完整踩坑筆記 + (含每一輪為什麼猜錯、最終正確架構)已整理進 + `~/my-agent/000_Agent/knowledge/learn/EasyLearn/feedback_rn-profile-modal-overlay-bugs.md`, + 跨專案通用的 RN 教訓也記到全域 CLAUDE.md。 ## 驗證紀律(跨 phase 都適用) diff --git a/yarn.lock b/yarn.lock index 92eebe1..d2504a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -840,11 +840,21 @@ resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@expo-google-fonts/jetbrains-mono@^0.4.1": + version "0.4.1" + resolved "https://registry.npmmirror.com/@expo-google-fonts/jetbrains-mono/-/jetbrains-mono-0.4.1.tgz#39d1390c386a7dd5ed5171e8acbbf41b86afd099" + integrity sha512-CslACrtMOcRwoWXCO7OMEI+9w3fukWSoBtvNz46OqPoogEuuoY0tkDY1O8sFumk8t0pC6Cx0Xr95O0TOQhpkug== + "@expo-google-fonts/material-symbols@^0.4.1": version "0.4.38" resolved "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz#88982312fb1a2ff18cd002874aaa9bbee0157727" integrity sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A== +"@expo-google-fonts/noto-sans-tc@^0.4.3": + version "0.4.3" + resolved "https://registry.npmmirror.com/@expo-google-fonts/noto-sans-tc/-/noto-sans-tc-0.4.3.tgz#1731f33484d429c08e9c32a5529223aae1c60faf" + integrity sha512-69XAOTLPz0WlAxL6qJEKlDMxHsBd4J1M39HjV0gPF2o1xWVALOsVWvDgistnecVockA9wbU/tH5C/gyS2PdnHg== + "@expo/cli@^57.0.6": version "57.0.6" resolved "https://registry.npmmirror.com/@expo/cli/-/cli-57.0.6.tgz#5457bf269be262562c545a57292cd39151b2164b" @@ -3998,6 +4008,11 @@ expo-keep-awake@~57.0.0: resolved "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz#718bb57f9e62205092dc7fa783a10d5d95a8480d" integrity sha512-WqEoyDNSmUeAI9Gu7UaWKDjOhfaV+jGcms7N0hh/EAr7sqJrI2s0HpLSC3P9cWfXFUZCL1zZjd22m/NbsJYCKg== +expo-linear-gradient@^57.0.0: + version "57.0.0" + resolved "https://registry.npmmirror.com/expo-linear-gradient/-/expo-linear-gradient-57.0.0.tgz#2c8566c3aa08d8bed550d58d5274e4a5e29363ec" + integrity sha512-jtZLklPhdc34QiGGGW0zGrOKMiBySPmxIi3uajQD4dPklpCUItx8FGzY1hqL5ygQyXqEIdCY4XEiQZIStkmFeg== + expo-linking@~57.0.2: version "57.0.2" resolved "https://registry.npmmirror.com/expo-linking/-/expo-linking-57.0.2.tgz#30f04e1fa73ad47a134b16815ddfaa39e58d824f"