Skip to content
Merged

Dev #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# React + Vite
# EasyLearn

This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Yarn workspaces monorepo:

Currently, two official plugins are available:
- `apps/web` — Next.js(App Router)網頁版,含 API routes(Prisma + Postgres)與 Clerk 登入
- `apps/mobile` — Expo React Native App,含 Clerk 登入與訪客模式(AsyncStorage)離線答題流程
- `packages/core` — web/mobile 共用的純資料與純函式(題庫、型別、計算邏輯),無 DOM/Prisma 依賴

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## 開發

## React Compiler
```bash
yarn install # repo 根目錄安裝一次即可
yarn web # 啟動網頁版(等同 yarn workspace web dev)
```

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the Oxlint configuration

If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
其餘文件見 `docs/`(`roadmap.md`、`curriculum.md`、`question-format.md`)。
9 changes: 9 additions & 0 deletions apps/mobile/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Clerk:跟 apps/web 用同一把 publishable key(Dashboard → API Keys),
# Expo 環境變數要 EXPO_PUBLIC_ 前綴才會被打進 client bundle
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# apps/web 的 API base URL:手機模擬器/真機連不到 localhost,
# 要用同一區網的電腦 LAN IP(例如 http://192.168.1.10:3001)或 Expo tunnel
EXPO_PUBLIC_API_BASE_URL=http://192.168.1.10:3001

# 複製這個檔案成 .env.local(已被 .gitignore 排除),不要直接改這個檔案
41 changes: 41 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

# generated native folders
/ios
/android
1 change: 1 addition & 0 deletions apps/mobile/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
7 changes: 7 additions & 0 deletions apps/mobile/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
21 changes: 21 additions & 0 deletions apps/mobile/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
46 changes: 46 additions & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"expo": {
"name": "EasyLearn",
"slug": "easylearn-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "easylearn",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.retsnom.easylearn-mobile"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "com.retsnom.easylearnmobile"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"@clerk/expo",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
}
}
}
61 changes: 61 additions & 0 deletions apps/mobile/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SymbolView } from 'expo-symbols';
import { Tabs } from 'expo-router';
import { useAuth } from '@clerk/expo';

import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';

// 對應 apps/web 的 Navbar.tsx 四個分頁(home/notes/stats/profile)
export default function TabLayout() {
const colorScheme = useColorScheme();
const { isSignedIn } = useAuth();

return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
headerShown: false,
}}>
<Tabs.Screen
name="index"
options={{
title: '每日刷題',
tabBarIcon: ({ color }) => (
<SymbolView name={{ ios: 'house', android: 'home', web: 'home' }} tintColor={color} size={26} />
),
}}
/>
<Tabs.Screen
name="notes"
options={{
title: '精選筆記',
tabBarIcon: ({ color }) => (
<SymbolView name={{ ios: 'book', android: 'book', web: 'book' }} tintColor={color} size={26} />
),
}}
/>
<Tabs.Screen
name="stats"
options={{
title: '學習數據',
tabBarIcon: ({ color }) => (
<SymbolView
name={{ ios: 'chart.bar', android: 'bar_chart', web: 'bar_chart' }}
tintColor={color}
size={26}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: isSignedIn ? '個人資料' : '登入',
tabBarIcon: ({ color }) => (
<SymbolView name={{ ios: 'person', android: 'person', web: 'person' }} tintColor={color} size={26} />
),
}}
/>
</Tabs>
);
}
109 changes: 109 additions & 0 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { View } from '@/components/Themed';
import { useProgress } from '@/hooks/useProgress';
import Home from '@/screens/Home';
import ChapterMap from '@/screens/ChapterMap';
import Quiz from '@/screens/Quiz';
import { chapters, getLevel, MIXED_SIZE, sampleQuestions, shuffle, type Question } from '@easylearn/core';

type ViewState =
| { name: 'home' }
| { name: 'levellist'; chapterId: string }
| { name: 'quiz'; levelId: string; questions: Question[] }
| { name: 'mixed'; questions: Question[] };

// Phase 3:訪客模式離線答題流程。跟 apps/web 的 App.tsx 一樣用一個 view 狀態機切畫面,
// 差別是這裡範圍只到 Home tab(levellist/quiz/mixed 都是從這個 tab 進去的子畫面),
// notes/stats tab 各自獨立,不需要共用同一個狀態機。
export default function HomeScreen() {
const { progress, hydrated, answerQuestion, toggleSaved, finishLevel, finishReview } = useProgress();
const [view, setView] = useState<ViewState>({ name: 'home' });
const insets = useSafeAreaInsets();

if (!hydrated) {
return (
<View style={[styles.loading, { paddingTop: insets.top }]}>
<ActivityIndicator />
</View>
);
}

const startLevel = (levelId: string) => {
const level = getLevel(levelId);
if (!level) return;
setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) });
};

const startMixedPractice = () => {
const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions));
const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty);
setView({ name: 'mixed', questions: picked });
};

let content;
if (view.name === 'quiz') {
const level = getLevel(view.levelId);
if (!level) return null;
const chapterId = chapters.find((ch) => ch.levels.some((l) => l.id === view.levelId))?.id ?? '';
content = (
<Quiz
key={view.levelId}
level={{ ...level, questions: view.questions }}
progress={progress}
answerQuestion={answerQuestion}
toggleSaved={toggleSaved}
finishLevel={finishLevel}
finishReview={finishReview}
onExit={() => setView({ name: 'levellist', chapterId })}
/>
);
} else if (view.name === 'mixed') {
content = (
<Quiz
key="mixed"
level={{ id: '__mixed__', title: '隨機綜合練習', questions: view.questions }}
mode="mixed"
progress={progress}
answerQuestion={answerQuestion}
toggleSaved={toggleSaved}
finishLevel={finishLevel}
finishReview={finishReview}
onExit={() => setView({ name: 'home' })}
exitLabel="回首頁"
/>
);
} else if (view.name === 'levellist') {
content = (
<ChapterMap
chapterId={view.chapterId}
progress={progress}
onStartLevel={startLevel}
onBack={() => setView({ name: 'home' })}
/>
);
} else {
content = (
<Home
progress={progress}
onOpenChapter={(chapterId) => setView({ name: 'levellist', chapterId })}
onMixedPractice={startMixedPractice}
/>
);
}

return <View style={[styles.flexFill, { paddingTop: insets.top }]}>{content}</View>;
}

const styles = StyleSheet.create({
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
flexFill: {
flex: 1,
},
});
5 changes: 5 additions & 0 deletions apps/mobile/app/(tabs)/notes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon';

export default function NotesScreen() {
return <ComingSoon title="精選筆記" phase="Phase 5" />;
}
Loading