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"