feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
# ==============================================================================
# 1. 操作系统与本地环境缓存
# ==============================================================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 根目录本地临时/缓存文件夹
.dart_pub_cache/
.home/
.workbuddy-ai/
# ==============================================================================
# 2. 编辑器与 IDE 配置 (IDE / Editor)
# ==============================================================================
.idea/
*.iml
*.ipr
*.iws
.vscode/
*.swp
*.swo
*~
# ==============================================================================
# 3. Flutter & Dart 编译与依赖产物
# ==============================================================================
.dart_tool/
.packages
.pub-cache/
.pub/
.flutter-plugins
.flutter-plugins-dependencies
build/
**/build/
**/coverage/
**/doc/api/
**/.dart_tool/
# 混淆与符号文件
app.*.symbols
app.*.map.json
# ==============================================================================
# 4. Android 本地配置与构建产物
# ==============================================================================
**/android/.gradle/
**/android/local.properties
**/android/captures/
**/android/app/debug/
**/android/app/profile/
**/android/app/release/
**/android/GeneratedPluginRegistrant.java
# 签名证书与私钥(敏感信息)
*.keystore
*.jks
*.key
# ==============================================================================
# 5. iOS / macOS 本地构建与依赖产物
# ==============================================================================
**/ios/Flutter/.last_build_id
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/Flutter/GeneratedPluginRegistrant.h
**/ios/Flutter/GeneratedPluginRegistrant.m
**/ios/.symlinks/
**/ios/Pods/
**/ios/Podfile.lock
**/ios/**/GeneratedPluginRegistrant.swift
**/macos/Flutter/ephemeral/
**/macos/Flutter/GeneratedPluginRegistrant.swift
**/macos/Pods/
**/macos/Podfile.lock
**/*.xcworkspace/xcuserdata/
**/*.xcuserdatad/
**/DerivedData/
# ==============================================================================
# 6. 本地环境变量与私密配置 (Secrets / Env)
# ==============================================================================
.env
.env.*
*.local
+519
View File
@@ -0,0 +1,519 @@
# A0 初始核心课程包:10 节完整微课
> 目标:让零基础用户建立自我介绍、数字信息、日常喜好与简短对话的核心能力;这 10 节是起始内容,不是进入 A1 的固定课时门槛。
> 每个教学分段约 10–15 分钟;一个种子主题可以分多段、多天完成。每个词和英文句均需配真人或高质量 TTS 音频;`/ /` 内为发音参考。本文是内容规格,实际素材和题目须制作、审校后才能标记可用。
## 全局内容规范
- 每段约 10–15 分钟包含预热、听说读写、对话和结尾独立尝试,不另加一遍词卡教学。预热认识声音与句义,之后直接在句中应用;只对用户需要的词回看。
- 每段末用一个新值/新图片的短任务撤掉提示,可复用对话最后一轮或写作最后一题。帮助按钮仍可恢复,使用后记辅助完成并进入后续补练;跳过保持待做。每日时间不足保存步骤,按《学习引擎规格》续学。
- 所有例句使用真实、礼貌、日常的英文;中文仅用于提示与解释。
- 发音目标是清楚、可理解;提示以“重音、连读、容易混淆的音”为主,避免用汉字替代标准发音或要求模仿特定口音。
- 口语识别若无把握,先确认转写、重说或改文字,不直接显示“你错了”;重复识别错误不能证明发音错误,通用专项练习由用户选择,不降低掌握度。
- 每课的 AI 对话只使用本课及先前课程的语言;AI 必须完成“任务目标”后自然结束。
- 本文“完成判定”是教学任务完成,不是词句掌握或阶段通过。带提示/文字可完成课程,但不算独立听说证据。出口门槛见 [A0 阶段标准](A0-STAGE-STANDARD.md),内容契约见 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md)。
- 每段最多 8 个新增词汇项、2 个主动产出句型和 2 个理解用词块;同句型的槽位替换、缩写不重复计数。下面列的是主题总内容,不能一次全部塞入一个短课。
- 教学可看字幕/中文;独立听力先隐藏文字,查看后标记辅助。课内阅读题保留英文;正式写作评估不用课内填空替代。
- 课程中可点英文单词或完整词块查词,优先显示当前短语的中文意思、示范音和本课例句;用户可加入复习。正式独立练习和出口评估隐藏查词入口,若需查词则改为教学练习并换题补测。
### 准备词块与分段(先教后用)
词块先整体听懂,不要求零基础用户立即分析每个功能词。每项准备词块都提供英文音频、中文意思及一次匹配练习;主动产出另做无提示回忆。未教过的英文干扰选项改为中文或已教词句,不让超纲选项增加负担。
| 主题 | 主动产出与分段 | 使用前必须准备的理解内容 |
|---|---|---|
| 1 | AHello/Hi、I'm [name]My name is 为可选同义);BNice to meet you(含 too 回应)、What's your name? | What's your name?(你叫什么?)、Nice to meet you.(很高兴见到你);另在短准备段教 Please say that again.(请再说一遍) |
| 2 | 按所选姓名先教所需字母,每段不超过 8 个;再练完整拼读与 How do you spell that? | How do you spell your name?(你的名字怎么拼?)、Thank you.(谢谢) |
| 3 | How are you?、I'm [state]today 为已说明修饰语 | thanks(谢谢);先教问题含 today 的意思 |
| 4 | AzerofiveBsixten、number、phoneC:问号码和报告三位号码 | What's your phone number?(电话号码是什么?)、Yes/No(是/不是) |
| 5 | What's this?、It's a/my [object] | Is it your book?(这是你的书吗?);确认 Yes/No 已准备 |
| 6 | Where are you from?、I'm from [place] | Nice!(真好!);自选地点先查证规范名称和音频,不任意造专名 |
| 7 | This is my [person]My family is in [place] 仅作可选后续段 | Who is this?(这是谁?)、Is this your mother?(这是你妈妈吗?) |
| 8 | AMondayWednesday、问星期与回答;BThursdaySunday 复用句型;Celeven/twelve、time/o'clock、问时间与整点回答 | What day is it today?(今天星期几?)、What time is it?(几点?);按对应分段引入 |
| 9 | AI like [thing]、Do you like [thing]?BYes, I do./No, I don't. 作为一个回应词块 | Great!(太好了!) |
| 10 | 无新语言,复用已教句型;缺失的准备内容先补练 | 补教 Please speak slowly.(请说慢一点),与请求重复一起练;不用没教过的礼貌长句考用户 |
### 姓名字母资源
内置完整 A–Z 字母名音频:A /eɪ/、B /biː/、C /siː/、D /diː/、E /iː/、F /ef/、G /dʒiː/、H /eɪtʃ/、I /aɪ/、J /dʒeɪ/、K /keɪ/、L /el/、M /em/、N /en/、O /əʊ/、P /piː/、Q /kjuː/、R /ɑːr/、S /es/、T /tiː/、U /juː/、V /viː/、W /ˈdʌbəljuː/、X /eks/、Y /waɪ/、Z /zed/(也接受 /ziː/)。接受常见英美变体,不混同字母名和单词中的字母发音。按所选姓名动态抽取,不要求先背完全部字母。
### 独立尝试与写作进阶映射
下表是各主题已有练习的独立版本,不是另加一套考试。分段主题把机会分散到各段结尾;单段主题在听、读、写任务末次作答时撤掉对应帮助,结尾保留一次口语尝试。听力不显示字幕,阅读只保留英文;选择题可用中文选项,不能先给正确答案。每次只按真实作答记录相应技能;下表短词/填空/排序成功不冒充完整句写作。不同题值只能来自已教范围。
| 主题 | 听力独立版本 | 阅读独立版本 | 口语独立版本 | 写作递进与独立版本 |
|---|---|---|---|---|
| 1 | 换已准备的人名听出姓名 | 新问候短文识别人名 | 隐藏脚本,问候并说姓名 | 先填姓名,再看中文“嗨”输入 Hi;完整句留后续复练 |
| 2 | 听已教学的姓名字母顺序 | 换字母排列核对姓名 | 隐藏拼写结果,完整拼所选姓名 | 看姓名独立输入字母序列,不作完整句证据 |
| 3 | 听出不同已教状态 | 阅读新人物状态 | 不看句框回应状态问题 | 先填状态,再把 I'm / tired 排序;仅记排序练习 |
| 4 | 各段只抽已教数字,C 段听新三位号码 | 阅读不同三位号码 | 报另一组虚拟号码 | 独立将新三位数字逐位写成英文 |
| 5 | 听另一件已教物品 | 看新物品短对话答题 | 换图片说物品 | 先排 It’s / a / pen,再只看笔的图片写 It's a pen.;提示后不算独立 |
| 6 | 听另一已准备地点 | 新人名/地点短文 | 无脚本说来自哪里并反问 | 先填地点,再只看中文任务写完整 I'm from …;次日加练完整姓名句 |
| 7 | 听出另一位家庭成员 | 新家庭图片介绍短文 | 换图片介绍一位家人/朋友 | 去掉 This is my 句框,看图写完整句 |
| 8 | 各段听已教星期/整点的新组合 | 阅读星期与时间短文 | 看新星期/钟面口头回答 | 先填词,再看新星期卡/钟面写完整句;B/C 段分别覆盖星期/时间 |
| 9 | 听出喜好对象或肯否 | 新喜好对话理解回答 | 说喜好、回答并反问 | 看中文目标自由写一句自己的喜好,不提供词库 |
| 10 | 听未训练的人物信息组合 | 阅读独立短介绍核对信息 | 按本课五项目标无小抄对话 | 收起小抄,独立写姓名、地点、喜好三句,再抽已教物品或时间句复练 |
第 1–4 课的完整姓名句在第 6 课和第 10 课补充独立练习;不得因为前期填空已完成就认为出口写作已准备。AI 判题不确定记待反馈。每天未完成的技能机会进入续学,不额外无限加题。
---
## 第 1 课:你好,我是……
**任务目标**:能够打招呼、说出姓名,并礼貌回应“很高兴见到你”。
**预计时间**12 分钟。
**情境**:第一天认识新同学 Mia。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | hello /həˈləʊ/ | 你好 |
| 词 | hi /haɪ/ | 嗨 |
| 词 | name /neɪm/ | 名字 |
| 词 | nice /naɪs/ | 高兴的、友好的 |
| 词 | meet /miːt/ | 见面 |
| 词 | too /tuː/ | 也 |
| 句 | Hello. Im Shen. | 你好。我是 Shen。 |
| 句 | My name is Shen. | 我的名字是 Shen。 |
| 句 | Nice to meet you. | 很高兴见到你。 |
| 句 | Nice to meet you, too. | 我也很高兴见到你。 |
**发音焦点**`Im``I am` 的自然短说法;`nice to` 连读时 `to` 很轻。
**微语法**`Im + 名字` 用来介绍自己;`My name is + 名字` 也是同样意思。两种任选一种即可。
### 课内步骤
1. **听懂**:播放 `Hello. Im Mia. Nice to meet you.`;题目:Mia 在做什么?选项为“自我介绍 / 买东西 / 问时间”。正确答案:自我介绍。
2. **词句跟读**:依次跟读 `Hello.``Im Shen.``Nice to meet you.`。每句可慢放和重录。
3. **阅读**
```text
Mia: Hello. Im Mia.
Shen: Hi. Im Shen.
Mia: Nice to meet you.
Shen: Nice to meet you, too.
```
题目:谁叫 Mia?正确答案:第一位说话的人。
4. **写一写**:填空 `Hello. Im _____.`;用户填写自己的名字。
5. **AI 对话**AI`Hi! Im Mia. Whats your name?`;用户说姓名。AI`Nice to meet you, [name].`;用户回应 `Nice to meet you, too.`
**完成判定**:说出或输入姓名,并回应一次 `Nice to meet you`。
**结课复习句**`Hi. Im _____.`
---
## 第 2 课:拼读我的名字
**任务目标**:听懂别人询问姓名拼写,并能逐个说出自己的名字字母。
**预计时间**14 分钟。
**情境**:咖啡店员工登记你的名字。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 字母 | A /eɪ/、E /iː/、M /em/、N /en/、S /es/、H /eɪtʃ/ | 常用姓名字母示例 |
| 词 | spell /spel/ | 拼写 |
| 词 | letter /ˈletə(r)/ | 字母 |
| 句 | How do you spell that? | 那个怎么拼? |
| 句 | S-H-E-N. | S-H-E-N。 |
| 句 | My name is Shen. S-H-E-N. | 我的名字是 Shen。S-H-E-N。 |
**发音焦点**:字母单独读,字母之间留极短停顿;`S` /es/ 不读成“斯”。
**微语法**`How do you ...?` 是“你怎样……?”的问法;本课只需把它当作完整的实用句记住。
### 课内步骤
1. **听懂**:播放 `How do you spell your name?`;选择正确意思:“你的名字怎么拼?”
2. **听辨**:播放 `M` 与 `N`、`A` 与 `H`;选择听到的字母,每组 2 题。
3. **跟读**:读 `S-H-E-N`,听示范并回听录音;然后拼读自己的所选姓名。识别不确定时先确认,不宣称已定位最不清晰的字母。
4. **阅读**
```text
Mia: Whats your name?
Shen: Shen.
Mia: How do you spell that?
Shen: S-H-E-N.
```
排序题:把 `S / H / E / N` 排成正确顺序。
5. **写一写**`My name is _____.` 填姓名,按实际姓名长度生成字母格,不固定三个字母。
6. **AI 对话**AI`Whats your name?` → 用户回答;AI`How do you spell that?` → 用户拼读;AI 用 `Thank you.` 收尾。
**完成判定**:完整拼出所选拉丁字母姓名,或输入全部字母;允许用拼音或虚拟名。可跳过当天拼读继续学习,但该能力保持待练;只说两个字母不视为完整姓名拼读达标,打字不算口语证据。
**结课复习句**`How do you spell that?`
---
## 第 3 课:你好吗?
**任务目标**:能问候对方,并说出自己今天的简单状态。
**预计时间**12 分钟。
**情境**:早上在办公室遇到同事。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | good /ɡʊd/ | 好的 |
| 词 | fine /faɪn/ | 很好、不错 |
| 词 | okay /əʊˈkeɪ/ | 还可以 |
| 词 | tired /ˈtaɪəd/ | 累的 |
| 词 | today /təˈdeɪ/ | 今天 |
| 句 | How are you? | 你好吗? |
| 句 | Im good, thanks. | 我很好,谢谢。 |
| 句 | Im okay. | 我还可以。 |
| 句 | Im tired today. | 我今天有点累。 |
**发音焦点**`How are you?` 自然快读时三个词会连在一起;`thanks` 末尾 /s/ 要轻收尾。
**微语法**`Im + 状态``Im good / okay / tired.` 用 `Im` 表示“我现在是……”。
### 课内步骤
1. **听懂**:听 `How are you today?`,选择“询问你的状态”。
2. **选择回应**:问题 `How are you?`,从 `Im good, thanks.`、`My name is Mia.`、`It is Monday.` 选正确回应。
3. **跟读**`How are you?`、`Im okay.`、`Im tired today.`
4. **阅读**
```text
Mia: Hi, Shen. How are you?
Shen: Im good, thanks. How are you?
Mia: Im okay.
```
题目:Mia 状态如何?答案:okay。
5. **写一写**:选择一个真实状态完成 `Im ____ today.`
6. **AI 对话**AI`Hi, [name]. How are you today?`;用户回答并反问。AI 用一种状态回答。
**完成判定**:完成一次问候和一次 `Im + 状态` 回答。
**结课复习句**`Im good, thanks.`
---
## 第 4 课:数字与电话号码
**任务目标**:能说 0–10,听懂并报出简单电话号码片段。
**预计时间**:每段 10–15 分钟,共 3 段,可分多天。
**情境**:练习填写虚拟三位号码,不输入真实联系电话。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 数字 | zero, one, two, three, four | 04 |
| 数字 | five, six, seven, eight, nine, ten | 510 |
| 词 | number /ˈnʌmbə(r)/ | 号码 |
| 词 | phone /fəʊn/ | 电话 |
| 句 | Whats your phone number? | 你的电话号码是多少? |
| 句 | My number is 1-3-8. | 我的号码是 1-3-8。 |
| 句 | One, three, eight. | 一、三、八。 |
**发音焦点**`three` /θriː/ 舌尖轻放在牙齿间;`five` 末尾 /v/ 要发声。
**微语法**:号码通常逐个数字读,而不是读成完整的数值。
### 课内步骤
1. **听辨**:数字 0–10 随机听辨 5 题;例如播放 `seven`,从 6 / 7 / 8 中选择 7。
2. **听写**:播放 `one-three-eight`,按顺序点击 1、3、8。
3. **跟读**:跟读 0–10;再跟读 `My number is one-three-eight.`
4. **阅读**
```text
Mia: Whats your phone number?
Shen: One-three-eight.
Mia: Thank you.
```
题目:Shen 的号码是?答案:138。
5. **写一写**:将 `139` 写成英文数字(`one-three-nine`)。
6. **AI 对话**AI`Whats your phone number?`;用户用 3 位虚拟号码回答。AI 复述:`One-three-eight?`;用户说 `Yes.`
**完成判定**:正确听辨至少 4/5 个数字,且在对话中报出 3 位号码。
**结课复习句**`My number is ____.`
---
## 第 5 课:这是什么?
**任务目标**:能询问和说出几个身边常见物品。
**预计时间**13 分钟。
**情境**:同事指着桌上的物品问你。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | book /bʊk/ | 书 |
| 词 | phone /fəʊn/ | 手机 |
| 词 | pen /pen/ | 笔 |
| 词 | bag /bæɡ/ | 包 |
| 词 | key /kiː/ | 钥匙 |
| 词 | water /ˈwɔːtə(r)/ | 水 |
| 句 | Whats this? | 这是什么? |
| 句 | Its a book. | 这是一本书。 |
| 句 | Its my phone. | 这是我的手机。 |
**发音焦点**`this` 的 /ð/ 舌尖轻触上下齿之间,气流通过,不用用力咬;`It's` 保留清楚的词尾,不额外加元音。
**微语法**`What's this?` 问一个近处物品;单个可数物品用 `It's a book/pen/...`。`my` 后不再加 a;water 本课说 `It's water.`,不套用 `a water`。
### 课内步骤
1. **看图听词**:6 张物品图,听词后点击对应图片。
2. **听懂**:播放 `Whats this? Its a key.`;选择图片“钥匙”。
3. **跟读**`Whats this?`、`Its a pen.`、`Its my bag.`
4. **阅读**
```text
Mia: Whats this?
Shen: Its a book.
Mia: Is it your book?
Shen: Yes.
```
题目:这是谁的书?答案:Shen 的。
5. **写一写**:先看手机图填 `It's my _____.`;再排序 `It's / a / pen`;收起句框和词库,换已教物品图片,独立写一句。笔图参考 `It's a pen.`,书图参考 `It's a book.`;需要帮助可恢复,但标记辅助完成。
6. **AI 对话**:AI 描述并出示物品图:`Whats this?`;用户回答。连续 3 个物品后结束。
**完成判定**:正确说出或输入 3 个物品句。
**结课复习句**`Its a ____.`
---
## 第 6 课:我来自……
**任务目标**:能说自己来自哪里,并礼貌询问对方。
**预计时间**13 分钟。
**情境**:在国际活动上认识新朋友。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | from /frɒm/ | 来自 |
| 词 | China /ˈtʃaɪnə/ | 中国 |
| 词 | Hong Kong /ˌhɒŋ ˈkɒŋ/ | 香港 |
| 词 | city /ˈsɪti/ | 城市 |
| 词 | country /ˈkʌntri/ | 国家 |
| 句 | Where are you from? | 你来自哪里? |
| 句 | Im from Hong Kong. | 我来自香港。 |
| 句 | Im from China. | 我来自中国。 |
**发音焦点**`from` 的元音短;`Hong Kong` 两个词都有重音。
**微语法**`I'm from + 地点` 表示“我来自……”。本课 China、Hong Kong 前不加 the;并非所有地点都这样,例如 the UK、the Netherlands。自选地点以已核对的表达为准。
### 课内步骤
1. **听懂**:听 `Im from Hong Kong.`,选择“香港”。
2. **句子拼装**:把 `Im / from / China` 拖成正确句子。
3. **跟读**`Where are you from?` 和两个回答句。
4. **阅读**
```text
Mia: Where are you from?
Shen: Im from Hong Kong.
Mia: Nice!
```
题目:Shen 来自哪里?答案:Hong Kong。
5. **写一写**:先填 `I'm from _____.`,再收起范句,仅显示中文任务“用完整英文句介绍你来自哪里”,允许自选已准备的真实/虚拟地点。参考 `I'm from China.`,只写 China 不算完整句。次日另用“介绍姓名”任务独立写 `I'm …` 或 `My name is …`,纳入复习预算。
6. **AI 对话**:AI 先问姓名(复习第 1 课),再问 `Where are you from?`;用户回答后反问 AI。
**完成判定**:能够用 `Im from + 地点` 回答,并至少问一次 `Where are you from?`。
**结课复习句**`Im from ____.`
---
## 第 7 课:我的家人
**任务目标**:能够简单介绍一位家人。
**预计时间**15 分钟。
**情境**:向新朋友展示一张家庭照片。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | family /ˈfæməli/ | 家人、家庭 |
| 词 | mother /ˈmʌðə(r)/ | 母亲 |
| 词 | father /ˈfɑːðə(r)/ | 父亲 |
| 词 | sister /ˈsɪstə(r)/ | 姐妹 |
| 词 | brother /ˈbrʌðə(r)/ | 兄弟 |
| 词 | friend /frend/ | 朋友 |
| 句 | This is my mother. | 这是我的妈妈。 |
| 句 | This is my brother. | 这是我的兄弟。 |
| 句 | My family is in Hong Kong. | 我的家人在香港。 |
**发音焦点**`mother` 与 `brother` 中的 `th` 发 /ð/`family` 中间音节自然弱读。
**微语法**:介绍一个人时用 `This is my + 身份`。`my` 后不加 `a`:说 `my mother`,不说 `my a mother`。
### 课内步骤
1. **看图听词**:听 `mother / father / sister / brother` 后选家庭图片。
2. **听懂**:听 `This is my sister.`,选择“姐妹”照片。
3. **跟读**`This is my mother.`、`This is my brother.`
4. **阅读**
```text
Mia: Who is this?
Shen: This is my father.
Mia: Your father?
Shen: Yes.
```
题目:照片里的人是谁?答案:Shen 的父亲。
5. **写一写**:先选词填 `This is my _____.`,再收起句框和词库,换一张已教家庭成员图独立写完整句。父亲图参考 `This is my father.`;允许虚构家庭。
6. **AI 对话**AI`Who is this?`(显示家庭成员图);用户回答。AI 追问 `Is this your mother?`;用户答 `Yes.` 或 `No.`
**完成判定**:用 `This is my ...` 介绍一位家人。
**结课复习句**`This is my ____.`
---
## 第 8 课:今天星期几?
**任务目标**:能问和回答星期几,并说简单时间。
**预计时间**:每段 10–15 分钟,共 3 段,可分多天。
**情境**:和同事确认星期与当前整点。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | Monday /ˈmʌndeɪ/ | 星期一 |
| 词 | Tuesday /ˈtjuːzdeɪ/ | 星期二 |
| 词 | Wednesday /ˈwenzdeɪ/ | 星期三 |
| 词 | Thursday /ˈθɜːzdeɪ/ | 星期四 |
| 词 | Friday /ˈfraɪdeɪ/ | 星期五 |
| 词 | Saturday /ˈsætədeɪ/ | 星期六 |
| 词 | Sunday /ˈsʌndeɪ/ | 星期日 |
| 数字 | eleven /ɪˈlevən/、twelve /twelv/ | 十一、十二(整点用准备词) |
| 词 | today /təˈdeɪ/ | 今天 |
| 词 | time /taɪm/ | 时间 |
| 词 | oclock /əˈklɒk/ | ……点钟 |
| 句 | What day is it today? | 今天星期几? |
| 句 | Its Monday. | 今天星期一。 |
| 句 | What time is it? | 现在几点? |
| 句 | Its three oclock. | 现在三点。 |
**发音焦点**`Wednesday` 通常是两个音节 /ˈwenz.deɪ/,重音在第一个音节;`o'clock` 重音在第二部分。
**微语法**:回答星期和整点时,使用 `It's + 星期/整点时间`;日历日期不在本课。
### 课内步骤
1. **听辨**A 段只练 MondayWednesdayB 段加入 ThursdaySunday,各先看词听音再听辨。C 段先练 eleven/twelve,再随机听辨 112 整点。
2. **听懂**:播放 `What day is it today? Its Tuesday.`;选择 Tuesday。
3. **跟读**:三句核心句,重点练 Wednesday。
4. **阅读**
```text
Mia: What day is it today?
Shen: Its Monday.
Mia: What time is it?
Shen: Its three oclock.
```
题目:现在几点?答案:three o’clock。
5. **写一写**C 段先看 5:00 填 `It's _____ o'clock.`,再收起句框,换已教整点钟面独立写完整句(3:00 参考 `It's three o'clock.`)。B 段以相同撤提示步骤练星期句,例如星期五卡参考 `It's Friday.`。
6. **AI 对话**A/B 段问 `What day is it today?`,仅用已教星期卡;七天都准备后可答真实星期。C 段再问 `What time is it?`,用 1–12 整点钟面。未教过的星期和数字不计错。
**完成判定**:正确完成一个星期回答和一个整点回答。
**结课复习句**`Its _____ oclock.`
---
## 第 9 课:我喜欢……
**任务目标**:能表达一种喜欢的食物或活动,并询问对方。
**预计时间**13 分钟。
**情境**:午休和新朋友聊天。
### 学习内容
| 类型 | 内容 | 中文 |
|---|---|---|
| 词 | like /laɪk/ | 喜欢 |
| 词 | coffee /ˈkɒfi/ | 咖啡 |
| 词 | tea /tiː/ | 茶 |
| 词 | music /ˈmjuːzɪk/ | 音乐 |
| 词 | movies /ˈmuːviz/ | 电影 |
| 词 | food /fuːd/ | 食物 |
| 句 | I like coffee. | 我喜欢咖啡。 |
| 句 | I like music. | 我喜欢音乐。 |
| 句 | Do you like tea? | 你喜欢茶吗? |
| 句 | Yes, I do. / No, I dont. | 是的,我喜欢。/ 不,我不喜欢。 |
**发音焦点**`I like` 中 `I` 与 `like` 连贯;`dont` 的尾音 /t/ 要轻收。
**微语法**`I like + 东西/活动` 表达喜好。问对方时用完整句 `Do you like + 东西?`;短答为 `Yes, I do.` 或 `No, I dont.`
### 课内步骤
1. **听懂**:听 `I like music.`,选“音乐”图。
2. **判断**:问题 `Do you like tea?`,匹配 `Yes, I do.` 和 `No, I dont.` 的中文含义。
3. **跟读**`I like coffee.`、`Do you like tea?`、两种短答。
4. **阅读**
```text
Mia: I like tea. Do you like tea?
Shen: Yes, I do.
Mia: Great!
```
题目:Shen 喜欢茶吗?答案:喜欢。
5. **写一写**:先选词填 `I like _____.`,再收起句框和词库,只给中文目标“写一句你自己的喜好”。参考 `I like music.`,也接受其他已教对象,不要求照抄例句。
6. **AI 对话**AI`I like music. Do you like music?`;用户回答。AI 随后问另一项喜好,用户反问一次。
**完成判定**:说出一个 `I like ...`,并回答或提出一次 `Do you like ...?`。
**结课复习句**`I like ____.`
---
## 第 10 课:A0 小对话挑战
**任务目标**:在 45–90 秒内完成一段认识新朋友的对话:问候、姓名、来自哪里、状态或喜好。
**预计时间**15 分钟。
**情境**:参加一个国际交流活动,认识新朋友 Alex。
### 复习语言
| 能力 | 可用表达 |
|---|---|
| 问候 | `Hello.` / `Hi.` / `Nice to meet you.` |
| 姓名 | `Im ...` / `My name is ...` / `Whats your name?` |
| 来自哪里 | `Im from ...` / `Where are you from?` |
| 状态 | `How are you?` / `Im good, thanks.` |
| 喜好 | `I like ...` / `Do you like ...?` |
**发音焦点**:优先追求清楚、自然停顿和完整回应;无需追求口音。
**微语法复盘**:所有表达都来自四个简单框架:`Im + 信息`、`I like + 东西`、`Whats/Where/How + 问题`、`Do you like + 东西?`。
### 课内步骤
1. **听懂**:播放完整对话:
```text
Alex: Hi! Im Alex. Whats your name?
Shen: Hi! Im Shen. Nice to meet you.
Alex: Nice to meet you, too. Where are you from?
Shen: Im from Hong Kong. How are you?
Alex: Im good, thanks. Do you like coffee?
Shen: Yes, I do.
```
题目:Shen 来自哪里?Shen 喜欢什么?
2. **排序**:将五张卡按自然对话顺序排列:问候 → 姓名 → 来自哪里 → 状态 → 喜好。
3. **关键句跟读**:用户任选 3 句重说;系统给“清楚/再慢一点/可重说”的支持性提示。
4. **写一写**:先填小抄 `I'm _____. I'm from _____. I like _____.` 作准备;完成另一项练习后收起小抄,仅按中文目标独立写姓名、地点和喜好三句,不提供句框或词库。原样立即抄写仅算练习;再用不同物品图片/钟面补一项完整句练习,可留到次日预算内完成。
5. **AI 挑战对话**
- AI 开场:`Hi! Im Alex. Whats your name?`
- 开场提问姓名,后续每轮只问一个问题:来自哪里 → 状态或喜好;用已预教中文界面提示“最后请问对方一个已学问题”,不直接展示英文问句。该目标说明不算答案提示。
- AI 最多 5 个用户回答轮;若用户卡住,可给句框并标记辅助完成。
- 唯一完成清单为:问候、姓名、地点、一项状态/喜好、用户反问。前四项完成后留出一次用户提问机会;AI 回答后再收尾,不能提前结束。若第 4 个回答轮仍缺项,第 5 轮只补一个目标;上限后仍缺项则标记待补练,不算任务成功。允许更早完成,反问可自然出现在任一轮。
6. **总结重说**:从用户回答中选一句最有价值的句子,例如 `Im from Hong Kong.`,让用户立即重说。
**完成判定**:表达问候、姓名、地点及一项状态/喜好,并反问一次;不以轮数或秒数独立判定能力。
**本课边界**:仅为种子综合练习,不是 A0 结业考。A0 通过另须核心词句门槛和两次四技能评估,覆盖姓名拼读、数字、物品、家人、星期/时间及补救表达;未通过继续生成同阶段练习。
## A0 复习安排
| 各词句首次完成正式教学后(不等待整课结束) | 复习内容 | 形式 |
|---:|---|---|
| 第 1 天 | 本课关键句 | 30 秒 AI 微对话 + 重说 |
| 第 3 天 | 易错词或句型 | 听辨或替换词造句 |
| 第 7 天 | 场景迁移 | 相似但不同的 AI 情境对话 |
| 第 14 天 | 综合复习 | 2–3 分钟任务型对话 |
如果同一个关键句连续两次未完成,则在次日再次出现,且先提供句子框架,再逐步隐藏提示。
上表为正常学习日程;逾期、辅助完成和技术失败按《学习引擎规格》处理。离线可用审核模板替代 AI 微对话,不能可靠判定的开放作答留待反馈。
## 内容入库检查
- 每个分段标明对应 A0-C 能力、核心词句 ID、准备词块和先修内容;不按主题标题自动推断已教内容。
- 逐题核对参考答案、允许的等义回答与新词预算;题内英文干扰项也要在允许范围。
- 为全部新增字母、星期、eleven/twelve、准备问句制作音频,抽听音文一致性;未有音频不发布为听力任务。
- 将 A0 出口题组独立于种子课练习制作、审校,不能直接将第 10 课成绩当作阶段通过。
- 本次修订纠正文本与教学边界,不代表音频、图片或完整运行题库已经制作完毕。
+110
View File
@@ -0,0 +1,110 @@
# A0 阶段标准与出口评估
> stageVersiona0-v1rubricVersiona0-exit-v12026-09-12
> A0 是本应用的零基础起步级,不是正式 CEFR 认证。以下阈值为个人学习的初始操作规则,需结合实际使用复核;不承诺学习时长或真人沟通成功率。
## 1. 固定能力地图
“前置”表示开始新能力前已完成相应准备教学且做过一次有效练习,不要求每个前置词先完成 14 天复习。继续练旧能力不限次数。第 10 课只是综合练习,不是本评估的替代品。
| 能力 ID | 能力 | 前置 | 种子主题与补充要求 |
|---|---|---|---|
| A0-C01 | 问候、说姓名、询问姓名 | 无 | 第 1 课 |
| A0-C02 | 听辨并完整拼读所选姓名 | C01 | 第 2 课;按姓名补字母,允许虚拟姓名 |
| A0-C03 | 询问与表达状态 | C01 | 第 3 课 |
| A0-C04 | 听辨 010、报告三位数字片段 | C01 | 第 4 课分段 |
| A0-C05 | 识别与介绍常见物品 | C01 | 第 5 课 |
| A0-C06 | 询问与表达来自哪里 | C01 | 第 6 课 |
| A0-C07 | 介绍家人或朋友 | C05 | 第 7 课,允许虚构人物 |
| A0-C08 | 表达星期和整点时间 | C04 | 第 8 课分段补齐七天与 eleven / twelve |
| A0-C09 | 表达与询问喜好 | C01 | 第 9 课 |
| A0-C10 | 综合互动及请求重复/放慢 | C01–C09 | 第 10 课与补救短课;求助句从第 1 课起逐步预教 |
每项能力分别记录听说读写的证据,不因“课已完成”自动勾选全部技能。M1 不要求真实个人信息,姓名、家人、地点和号码均可使用虚拟资料。
## 2. 核心词句清单:固定 60 项
以下是升级比例的唯一分母。每行按从左到右顺序对应 ID;同义形式、缩写和槽位替换共享原 ID,不增加分母。词块整体计算,不把其中功能词再计入核心词数。
| ID 范围 | 核心词汇(40 项) |
|---|---|
| A0-W01W11 | zero, one, two, three, four, five, six, seven, eight, nine, ten |
| A0-W12W18 | Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday |
| A0-W19W24 | book, phone, pen, bag, key, water |
| A0-W25W28 | mother, father, sister, brother |
| A0-W29W32 | coffee, tea, music, movies |
| A0-W33W35 | good, okay, tired |
| A0-W36W40 | hello, hi, yes, no, thanks |
| ID | 核心词块/句型(20 项) | 可接受形式说明 |
|---|---|---|
| A0-P01 | I'm [name]. | My name is [name]. / I am [name]. |
| A0-P02 | What's your name? | What is your name? |
| A0-P03 | Nice to meet you. | 回应可加 too |
| A0-P04 | How do you spell that? | How do you spell your name? |
| A0-P05 | How are you? | 可加 today |
| A0-P06 | I'm [state]. | 如 I'm good, thanks. |
| A0-P07 | What's your phone number? | 可展开 What is |
| A0-P08 | My number is [digits]. | 号码场景允许直接逐位报数字 |
| A0-P09 | What's this? | 可展开 What is |
| A0-P10 | It's a [object]. | my 替代 awater 用 It's water.,不用 a water |
| A0-P11 | Where are you from? | 固定问句 |
| A0-P12 | I'm from [place]. | 接受真实或虚拟地点;专名按对应表达处理冠词 |
| A0-P13 | This is my [person]. | 介绍家人或朋友 |
| A0-P14 | What day is it today? | What day is it? |
| A0-P15 | It's [weekday]. | 可展开 It is |
| A0-P16 | What time is it? | 固定问句 |
| A0-P17 | It's [hour] o'clock. | 112 整点 |
| A0-P18 | I like [thing]. | 内容限已教词汇 |
| A0-P19 | Do you like [thing]? | 包含回应词块 Yes, I do. / No, I don't. |
| A0-P20 | Please say that again. | Please speak slowly. 作为同组沟通补救策略 |
姓名所需字母、地点专名、eleven/twelve、friend、Who is this?、Is it your …?、Thank you. 等属于准备/辅助材料,仍需先教后测并保存证据,但不加入上述 60 项分母。它们的实际运用由能力任务检查,不允许以“不在分母”为由跳过教学。生成的其他扩展词也不增加分母。
P19 的问句和肯定/否定回应都要有证据,不能只会其中一种就将整组标记可使用;P20 至少有一次无提示的重复或放慢请求,另一种完成教学准备。其他可接受同义形式任一有效即可,不要求全部背诵。
核心清单变更须新建 stageVersion,提供显式旧新 ID 映射和迁移说明,不静默改变在学用户的分母。已通过记录保留原版本;新标准只能提示可选补充评估。
## 3. 升级门槛
进入下一阶段必须同时满足:
1. 60 项中至少 48 项为“可使用”或“已掌握”,其中至少 30 项为“已掌握”;这是初始工程阈值,不代表语言学上的精确掌握百分比。
首次判定通过时,需复核的词句暂不计入上述项数;复核恢复后按实际状态计入。这里统计的是词句项数,不是题数;一题覆盖多项须逐项有直接证据,单次遗忘的处理遵循学习引擎 3.2,不撤销既有阶段通过。
2. 下述两次不同题组的四技能出口评估均通过,间隔至少 24 小时,最近一次距当前不超过 30 天。题目值/顺序/人物至少变化,不重复训练原题作为第二份独立证据。
3. 两次评估覆盖 C01–C10 全部能力,必需任务不得被总平均分掩盖;任一技能 pending 或证据被隔离则不能通过。
可以随时申请评估,也可以延后,但不能手动强制升级。达到课程末尾而未通过时,继续同阶段复习和变式。30 天有效期仅在首次判定通过时检查;通过后不会因时间经过或一次遗忘自动撤销,改为推荐巩固。发现原题/证据无效则标记待复核并解释原因。A0 通过与 A1 内容是否就绪分开保存,详见《M1 开发与验收约定》。
## 4. 四技能题组蓝图
每个题组约 12–18 分钟,可按技能分段,7 天内完成同一题组;超过后旧部分保留学习记录,但需新题组取得阶段评估证据。第二题组在第一题组完成至少 24 小时后开始。正式评估只选已审核题目或确定性模板实例,不临时使用未审核的 AI 自由生成试题。
| 技能 | 每题组内容 | 通过条件 |
|---|---|---|
| 听 L | 10 道独立音频理解题:问候/姓名 1、姓名拼写 1、状态 1、数字 2、物品 1、地点 1、家人 1、星期或时间 1、喜好 1 | 至少 8/10;两道数字题至少 1 对;星期/时间题必须对,两组分别覆盖星期与时间 |
| 说 S | 初次见面任务最多 5 个用户轮:问候+姓名、地点、状态/喜好、反问;另做完整姓名拼读、三位数字、物品、家人、星期、整点、请求重复/放慢共 7 个微任务 | 主任务四类信息都有效,7 个微任务全部有效;不要求固定速度或凑满一分钟 |
| 读 R | 两段各 3–5 句的短对话,总计 5 题,覆盖人物/地点/状态或喜好及数字/时间信息 | 至少 4/5,数字/时间题必须对 |
| 写 W | 3 个独立表达:介绍自己、来自哪里、喜好;1 个物品句、1 个星期/时间句 | 至少 4/5,其中介绍自己、地点、喜好三项必过;填写个人信息槽不等于独立造句 |
说写每个任务按“关键信息完整、意义适合题目、使用可理解的已教表达”判定有效;不要求参考答案原样复现。轻微标点、大小写或不阻碍理解的语法问题只给建议;意思反转、关键数字错误、缺少要求信息判未通过;识别不确定记 pending 而非 incorrect。受控口语以未编辑、用户确认的转写及任务信息作有限代理证据,不输出真人可理解度保证。
### 4.1 无提示规则
- 系统导航和任务目标可用中文,但不提供目标英文、句框、单词释义或问题翻译。
- 听力默认隐藏英文字幕和中文;每题最多 2 次播放,采用预设清晰慢速音频,不额外变速。超出次数可继续练习,但本题评估作废,另题补测。
- 阅读仅显示英文材料;写作不提供词库、句框或答案。口语不展示 AI 提问文字,先播音频;可用 P20 请求重复/放慢,主任务最多两次,不视为语言提示。
- 一旦查看翻译/提示,当前受影响题改为练习证据,显示“这题需换题独立完成”,不惩罚练习中的求助。
- 口语打字或修改转写只保存为文字表达,口语项 pending。录音回听与确认未改写的转写不视为句子提示。
### 4.2 补测和复核
未通过项按技能与能力 ID 进入补练。完成至少一次相关补练后可换题补测该技能,已通过技能在题组的 7 天窗口内保留;同一道题立即重做不计独立证据。设备/网络问题可恢复或换等价题,不强制先补练。
开放题判定 uncertain 时不给通过或失败。用户可标记误判,系统隔离该证据并用不同题复核;用户点击“我答对了”本身不能解锁阶段。若设备持续无法取得口语证据,显示“口语待评估”,其余学习可继续。
## 5. 题库交付要求
开发时按上述蓝图制作两套至少各含 L10、S 主任务及 7 微任务、R5、W5 的审核题组,再为每种补测题型提供一个替换实例。每题必须有 ID、能力映射、实际素材、参考答案、可接受变体、判分及提示策略;使用模板参数生成的实例也须校验数字和文本一致性。题库尚未制作/审校前,不能把出口评估标记为已交付。
例:听力音频为 `My number is one-three-eight.`,答案 138;不是按屏幕文字作答。写作题中文任务为“用英语说你来自哪里”,`I'm from Hong Kong.` 有效,`Hong Kong` 未达到句子表达要求;口语中问姓名允许直接报名字,但主任务仍需完成问候与其他信息。
+217
View File
@@ -0,0 +1,217 @@
# 开口英语(暂定名)— AI 对话模块规格
> 版本:1.12026-09-12
> 模块定位:总产品中负责口语输出与真实情境练习的核心模块
> 核心承诺:每天 5–10 分钟,完成一次你在真实生活中用得上的英语对话。
## 1. 模块决策
M1 范围和异常验收以 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准,阶段通过以 [A0 阶段标准](A0-STAGE-STANDARD.md) 为准。本文 A1/A2 场景仅为后续示例。
总产品保留分级课程、词汇句型、听说读写练习、个性化复习和学习进度。AI 对话是其中承接“开口输出”的核心模块:它接收课程学到的词句,并在真实情境中让用户练习使用。
不做用户与用户之间的社交匹配、群聊或公开动态。用户打开“对话”页后,选一个生活场景,用语音或文字与 AI 完成一段有目标的英文对话;结束后获得少量、准确、可立即重说的反馈。所有功能都必须帮助用户更敢说、更能说。
## 2. 目标用户与问题
目标用户是中文母语、英语 A0–A2 的成人。他们常见的困难是:知道一些单词,却无法在真正的场景中及时组织语言;害怕出错,也没有稳定、安全的开口对象。
产品解决的问题不是“教完所有英语知识”,而是让用户反复经历:听到一句英语 → 用自己会的英语回应 → 被理解 → 获得一个可改进点 → 再说一次。
## 3. 最小产品闭环
```text
选择场景
→ 选择身份与难度
→ 与 AI 进行语音或文字对话
→ 完成明确任务
→ 获得简短反馈
→ 重说关键句 / 明天继续练习
```
M1 通常 3–5 分钟,最多 5 个用户回答轮;简单种子任务达标后可以提前结束,不为凑轮数追问。用户能点“提示”“慢一点”“翻译”“重说”;求助不终止练习,但须记录辅助情况。达到轮数上限未完成目标时标记未完成并提供补练,不自动判成功。
## 4. 首版页面
| 页面 | 目的 | 必须内容 |
|---|---|---|
| 欢迎与定位 | 了解用户起点和目标 | 目标选择、每日时长、可跳过的 3 分钟口语测评 |
| 对话入口 | 让用户立刻开口 | 一个推荐场景、继续上次对话 |
| 场景页 | 让用户自主选择练习任务 | 分级场景卡、目标、预计时长 |
| 对话页 | 完成交流 | AI 回复、语音/文字输入、求助按钮、进度提示 |
| 总结页 | 把对话转成成长 | 完成目标、一个改进点、重说、加入复习 |
| 我的 | 看见可用能力 | 已完成场景、常用句、需复习表达、目标 |
总首页仍以“今天的课程或到期复习”为唯一主任务;进入“对话”页后,其主要按钮是 `开始推荐对话`。课程目录由“学习”页管理,不与对话入口冲突。
## 5. 对话模式
### 5.1 情境任务(首版核心)
用户选择一个真实情境,AI 扮演相关角色,双方完成可验证的任务。AI 在必要时以中文给出极短提示,但对话本身以英文进行。
后续 A1 示例(不是 M1 起始场景;M1 使用姓名、物品、喜好等 A0 任务):
```text
场景:咖啡店
用户身份:顾客
AI 身份:店员
目标:点一杯饮料,并确认订单。
AI: Hi! What would you like?
用户: Id like a tea, please.
AI: Sure. Hot or iced?
用户: Hot, please.
AI: Great. One hot tea. Is that all?
```
### 5.2 自由对话(M1 后)
完成 M1 的受控任务型对话后,用户可选择主题,如“介绍自己”“我的周末”“旅行计划”,与 AI 自由聊天。系统仍保留等级上限、轮次上限和主题边界,避免 AI 把零基础用户带入超出能力的复杂讨论。
自由对话的首页文案不应是“和 AI 聊任何事”,而应是“聊一个你想练的主题”。
## 6. 首批场景与完成条件
| 等级 | 场景 | 用户目标 | AI 的完成判定 |
|---|---|---|---|
| A0 | 初次见面 | 说姓名和来自哪里 | 用户表达姓名、地点;完成一次问候 |
| A0 | 认识新同学 | 问对方姓名 | 完成至少一问一答 |
| A0 | 说喜欢什么 | 说一种喜欢的食物/活动 | 说出 `I like ...` 或等义表达 |
| A1 | 咖啡店 | 点饮料 | 饮品 + 礼貌表达 + 确认 |
| A1 | 餐厅 | 点餐/询问推荐 | 提出一项需求和一个问题 |
| A1 | 超市 | 寻找商品 | 询问商品位置并理解回答 |
| A1 | 问路 | 找到车站 | 问地点并复述一项方向 |
| A1 | 打车 | 到达目的地 | 说清目的地并确认路线/价格之一 |
| A1 | 酒店入住 | 办理入住 | 姓名 + 预订/入住需求 |
| A1 | 约时间 | 约见面 | 提议时间并完成确认 |
| A1 | 生病求助 | 描述简单不适 | 说一个症状并回应一次追问 |
| A2 | 同事闲聊 | 交流周末/工作日常 | 连续完成 5 轮相关回应 |
M1 先提供与 A0 核心课对应的受控对话;这些场景是起始内容,不是 A0 的对话总量。若出口能力尚未达标,系统继续围绕同阶段的薄弱点生成受控变式对话;A1 的生活场景和 A2 内容均作为后续扩展。
## 7. 分级与对话控制
### 7.1 轻量定位
首次可选择基础并完成 3 分钟轻量定位。M1 只推荐 A0 内的起点,不标记阶段通过,不解锁 A1;用户可以回退或申请正式出口评估。
### 7.2 AI 对话规则
| 项目 | A0 | A1 | A2 |
|---|---|---|---|
| AI 单轮长度 | 一个短问题,可加一个简短回应词块;整轮不超过 20 个英文词 | 1–2 句 | 23 句 |
| 额外新词 | 每轮最多 1 个、整段最多 2 个,先教后用;评估为 0 | 后续阶段规范定义 | 后续阶段规范定义 |
| 提问方式 | 单一、明确问题 | 单一任务或问题 | 可有追问 |
| 语速 | 慢速默认 | 正常/慢速可切换 | 正常语速 |
| 中文辅助 | 教学可见;独立听力和评估隐藏 | 按需显示 | 用户主动请求 |
AI 必须记住当前场景、用户身份、完成目标、已知信息及难度上限。它不能突然转换角色、重复问已回答的问题,或为了“自然聊天”而跳过任务目标。
## 8. 对话交互细节
### 8.1 输入与输出
- 默认鼓励语音,提供清晰的按住说话或点击录音入口。
- 录音后转写,设备支持时可显示中间结果;发送前允许确认、编辑。M1 不依赖实时转写能力。
- AI 以文字 + 可播放语音回复;支持慢速播放、逐句重放。
- 用户也能纯文字完成同一场景,保证在公共场所或静音环境下可用。
- 低可信度、没有可靠置信度、结果异常或用户质疑时,先显示“我没有完全听清”,让用户确认、修改或重说;不得直接判为语言或发音错误。修改转写仅保留文字表达证据,不算独立口语;所有确认/修改均保存标记。
- AI 消息及课程转写稿中的英文支持点词/点短语。弹出的查词卡优先解释完整短语,再解释单词;显示当前语境中文意思、播放、慢速和一个已学例句,可手动加入复习。关闭卡片后回到同一轮会话,AI 不重新生成回复。
### 8.2 四个固定求助按钮
| 按钮 | 行为 |
|---|---|
| 提示 | 给出 1 个当前轮次可用的短句框架,不直接代答 |
| 慢一点 | 用更慢音频重复 AI 上一句 |
| 翻译 | 显示 AI 上一句自然中文意思 |
| 重说 | 重新播放 AI 上一句,不改变对话状态 |
| 查词 | 点消息中的词或短语,查看当前语境释义和例句;关闭后回到当前轮 |
### 8.3 纠错时机
对话中不逐句打断。只有以下情况给出一行内的提示:用户请求帮助、表达无法理解,或本场景关键目标无法达成。
对话结束后才给完整的微反馈,且一次最多一个语言问题。零基础用户首先需要成功表达,不需要一次性接受语法课。
M1 发音反馈限示范音、慢放、录音回听和可选通用练习,不评价像不像母语者。`three` 多次被转写为 `free` 可能是噪声或 ASR 错误,不能据此诊断 /θ/ 或降低掌握度;可在用户选择后展示通用对比词与动作提示,并标注“非个性化发音诊断”。真正的音素诊断需要音频证据和验证过的能力,后续另行评估。
## 9. 会话总结与复习
每次结束页必须有:
1. `你完成了什么`:以任务结果描述,如“你成功点了一杯热茶。”
2. `本次做得好的句子`:摘取一个用户的有效表达。
3. `下一次说得更好的句子`:只给一个纠正或更自然替换。
4. `再说一次`:用户立即录音重说该句,形成一次闭环。
5. `后续练习`:新教或需补救的目标进入次日复习,已有稳定词句保留原到期时间;同词句与其他复习原因合并,不因每次对话总结而强制明日重练。
复习优先采用 30–60 秒微对话,并结合听辨、回忆和改写,覆盖不同证据类型。M1 例如换人物再问姓名或状态。断网时使用审核过的本地/缓存模板及本地答案规则;自由回答不能可靠判定则保存 pending。不能把在线微对话作为复习的唯一途径。
教学态中查词是正常帮助;其使用情况随 attempt 保存,便于下一次优先安排认识/回忆练习。独立练习和出口评估隐藏查词入口;用户选择查词时,将当前题转为教学练习并换一题补测,不把它记为对话失败。
课程内对话与结尾独立尝试计入教学段预算,不额外叠加时间。可在最后一轮换已教内容值并撤掉句框/翻译,取得该轮独立证据;前面教学使用帮助不污染后面已明确切换的独立任务,但原题立即复述不算迁移。第 10 课按“问候、姓名、地点、状态/喜好、反问”五项统一判定,前四项齐全后须留用户提问机会,AI 回答再结束;达到五轮上限仍缺项则给待补练结果。
## 10. AI 提示词与质量要求
AI 系统需要传入结构化上下文:用户等级、母语、场景、角色、任务目标、允许词汇/句型、轮次、用户已回答信息、求助状态与安全限制。
词句的掌握状态、允许的新语言数量、失败后的降级练习,以及阶段出口任务,须遵循 [学习引擎规格](LEARNING-ENGINE-SPEC.md)。
AI 行为要求:
- 做友好、耐心的英语对话伙伴,默认使用目标等级的英语。
- 先回应含义,再推进任务;不把每句用户话改写成教学讲解。
- 只在用户需要时用简短中文辅助;不使用抽象语法术语。
- 任务完成后自然收尾,转入总结页,不无限聊天。
- 不假装真人;不输出仇恨、色情、自伤、违法或高风险专业建议。
- 医疗等场景只练习表达需求,明确不提供诊断或治疗建议。
## 11. 功能验收标准
| 功能 | 验收标准 |
|---|---|
| 场景开始 | 用户从首页进入一个推荐场景不超过两次点击 |
| 语音对话 | 用户可完成至少 3 轮录音回复,识别失败时仍能编辑或改用文字继续 |
| 难度控制 | A0 每轮最多一个明确问题,可加简短回应/开场词块,总计不超过 20 个英文词,不连问姓名和地点 |
| 目标判定 | A0 初次见面场景提取姓名、地点和问候证据;客户端检查信息槽,不只采信 AI 完成标记 |
| 求助 | 四种求助操作不丢失当前会话状态 |
| 总结 | 每段会话都生成一个积极反馈、一个改进句和一次立即重说机会 |
| 复习 | 次日出现短变式或本地模板练习;技术失败不改变掌握度 |
## 12. 模块边界
- 本模块不替代课程视频、文章、词汇、语法讲解和书写练习;这些仍由总产品的学习与复习模块提供;
- 用户之间的匹配聊天、群聊、排行榜和公开动态;
- 无限时长、无限话题的“陪聊”模式;
- 根据不透明语音分数给用户贴发音好坏标签。
## 13. 个人使用检查
不需要留存、活跃用户或增长指标。每次对话后只检查它是否真正帮助你练习开口。
| 检查项 | 满足标准 |
|---|---|
| 能否开始 | 能在首页两次点击内进入一个场景 |
| 能否完成 | 能完成 3–5 轮对话,不会被识别或理解失败卡住 |
| 是否有收获 | 结束后能说清一个做得好的表达与一个待重说的句子 |
| 是否能巩固 | 第二天能以短对话重练上一轮的薄弱表达 |
## 14. 版本顺序
### V1:可用的个人对话练习
对应总产品 M1:语音/文字输入、TTS 回复、A0 种子情境及按能力生成的受控变式、四个求助按钮、任务证据判定、总结页、次日复习和基础进度。场景数量不限;A1 不在 M1。
### V1.1:改善日常使用
对应总产品 M2:扩展 A1 场景与自由主题对话。目标推荐、基础进度和同阶段变式已包含在 M1;更多发音能力必须另行验证,不预设可用。
### V2:扩展能力
增加 A2 场景、旅行/职场对话包、个性化对话计划。在你能稳定完成 V1 场景且想继续学习时再投入。
## 15. 数据与隐私
录音、转写文本和对话记录仅供你个人练习。优先保存在本机,并提供清空录音、对话记录和学习进度的入口。语音识别低置信度时,应用必须允许编辑,不能直接将其判为语言错误。
+220
View File
@@ -0,0 +1,220 @@
# 学习引擎规格:每日内容、掌握判定与阶段升级
> 适用范围:个人使用的英语学习工具
> 原则:课程主线保持稳定;AI 只在明确边界内生成变式练习与对话。
M1 范围、生成与数据契约见 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md)A0 核心清单和四技能评估见 [A0 阶段标准](A0-STAGE-STANDARD.md)。以下规则是个人学习初始规则,不是 CEFR 认证标准。
## 1. 学习体系的边界
学习路径没有预设的总课数。当前 A0 的 10 节核心课和后续生活主题只是起始内容库;系统会根据掌握状态持续提供复习、变式任务和补救微课,直到达到阶段出口能力。它们不是“掌握英语”的终点。
阶段推进不取决于“学完多少课”。核心课用于建立能力骨架;当一个阶段的出口任务、词句掌握度和真实对话表现尚未达标时,系统继续生成符合该阶段范围的新情境与练习,而不是机械跳到下一阶段。
| 阶段 | 能力目标 | 主要学习形式 |
|---|---|---|
| A0 | 问候、自我介绍、数字、身边物品、基础短句 | 短课、模仿、受控对话 |
| A1 | 点餐、购物、问路、入住、预约等固定生活任务 | 场景课程、任务型 AI 对话 |
| A2 | 讲日常、过去经历、计划、比较、建议与简单问题解决 | 更长对话、短文本、变式任务 |
| B1 | 独立处理旅行、生活及一般工作交流;表达理由与经历 | 真实慢速输入、叙事、讨论、写作 |
| B2 | 较自然处理复杂讨论、长内容和工作沟通 | 自然语速输入、观点辩护、专业主题 |
“掌握英语”应被理解为逐步达成 B1/B2 的真实交流能力,而不是背完一套课。到 B1/B2 必须加入大量真实音频、视频、阅读、自由表达与阶段性任务测评。
## 2. 每日内容生成引擎
每天不由 AI 从零随机生成一整课。系统先从固定的能力地图中选内容,再让 AI 生成符合当前水平的例句、对话变式和纠错。
### 2.1 每日组成
```text
到期复习(按时间选任务)
→ 教学段:词汇预热 → 带帮助的听说读写 → 受控对话 → 独立尝试
→ 总结与保存;未完成步骤留待续学
```
| 模块 | 来源 | 目的 | 建议用时 |
|---|---|---|---:|
| 完整教学段 | 当前能力地图 | 包含预热、听说读写、一次受控对话、独立尝试;预热不重复计时 | 合计约 10–15 分钟 |
| 其中:词汇预热 | 新词与准备词块 | 认识声音和当前句义,之后在句中应用,不再重复整套词卡 | 占教学段 1–3 分钟 |
| 其中:受控对话与独立尝试 | 已教语言 | 通常 3–5 个回答轮;结尾一轮可作为独立尝试 | 占教学段约 3–5 分钟 |
| 到期复习与补救 | 到期词句和薄弱技能 | 合并同一目标的复习与错误修复,避免重复安排 | 按每日预算 |
每日时间是安排上限,不保证每次完成整段。10 分钟:复习/补救 3 分钟、教学或续学 6 分钟、总结 1 分钟;20 分钟:5 + 14 + 130 分钟:8 + 20 + 2。30 分钟教学额度可用于慢练或可选巩固,不强制再学新段。无到期项时可将空余时间用于教学或提前结束。预热、课程内对话均包含在教学额度中,不另外叠加。只做复习的当天,独立输出可嵌入复习任务。
预热只列出当前教学段真正会遇到的新内容:通常 3–6 项,最多 8 个词汇项加 2 个理解词块;超出则先拆教学段。每项以“英文 → 音频 → 当前句义中文 → 一个短例句”呈现,并至少有一次轻量听辨或中英匹配。用户可逐项播放、查词或跳过,之后仍能进入课程;预热是降低陌生感,不是背诵考试。
预热只记录 `exposed`,不把“看过卡片”记成认识、可回忆、可使用或已掌握。实际课程和间隔复习中的独立作答才改变掌握状态。到预算边界完成当前短题后保存下一步骤,不开启长对话;可提前结束或自主继续。10 分钟不足以做完教学段时分天完成,未完成的听、说、读、写及独立尝试保持待做,不因当天结束自动结课。
### 2.2 选择规则
1. 有到期项先按时间预算选任务,不设每天 5 个词句的硬上限。将同一词句的多条到期原因合并;从最早到期和近期真实错误两个队列轮流取项(先最早到期),某队列为空则从另一队列取。收藏扩展词也按到期时间参与,保证不会长期被错题挤占。已打开的未完成会话可先恢复。
2. 选定新教学段后,若包含未预热的新增内容,先进入课前词汇预热;恢复已开始教学段时不强制重复预热,用户可从课程页主动回看。新课按阶段主线顺序进行;用户可自行回退,不可被 AI 随机跳到未准备的高难主题。
3. 优先选最近两次失败或需要提示才能完成的词句作为错误修复项。
4. AI 对话使用“本教学段 + 已学且可回忆/可使用/已掌握”的语言范围,较弱项目必须预教;额外新词每轮最多 1 个、整段最多 2 个,先说明再使用。评估不得引入额外新词。
5. 仅对有效语言失败安排补救。提示如何撤除及复习如何推进按第 3 节,不以任意两次点击答对跳过间隔检查点。
### 2.2a 复习容量与积压
“词句项”是稳定词句 ID,“复习任务”是一道题或短对话,界面分别显示任务数与覆盖词句数。一个任务可计划覆盖 1–3 个词句,但只为有直接作答证据的目标记账。例如用户独立说 `I like tea.`,可支持 tea 与 I like 的表达证据,不能同时推断会 coffee、会听懂 tea 或会提问。选择一个句义答案不自动证明其中每个词都认识。每个目标分别保存结果和技能;同一目标同日最多推进一个间隔检查点。
按任务预计用时计算积压:若待复习预计时间超过当日复习预算的 2 倍,或存在逾期至少 7 天的项,则暂停推荐新的教学段,先续学已开始内容并处理到期项;原教学额度可转为复习/补救,仍保留总结时间。积压降至预算以内且没有逾期 7 天的项后恢复新段推荐。用户可主动选新课,首页说明推荐原因;不删除积压、不偷偷提高每日时长。预测用时以实际完成耗时滚动修正。
### 2.2b 从辅助学习到独立尝试
每个教学段必须安排一次 30–90 秒的独立尝试,优先使用结尾对话的一轮或写作的一题,不额外叠加整套测试。先完成带帮助练习,再换内容值/图片/人物并隐藏句框、答案、释义和翻译;听力隐藏提问文字,阅读保留英文材料。刚看过同一句答案后立即复述只算练习,不能充当迁移证据;当日独立成功也不替代次日及后续间隔检查点。
不会时可恢复提示:该次记辅助完成,安排下一次不同任务的独立尝试,不强制当场循环。跳过记待做,时间不够则续学。只有教学步骤已完成且独立尝试已有结果(成功、辅助、有效错误或待确认)才记录教学段完成;“待确认”仍不构成掌握。各段轮换听、说、读、写;每个种子主题结束前四技能至少各有一次独立尝试机会,见课程包映射。主题可完成而能力未掌握,后者进入复习和补练。
### 2.3 AI 生成输入格式
每次请求 AI 时必须携带以下结构化信息:
```text
当前阶段:A0 / A1 / A2 / B1 / B2
当前主题与任务目标:例如“在咖啡店点饮料”
用户身份与 AI 身份
已掌握的词、句型
本教学段新词(最多 8 项)及主动句型(最多 2 项)
理解用词块(每段最多 2 个);对话额外新词(每轮 1 个、整段 2 个;评估为 0)
禁止的超纲词与复杂语法
已出现的错误与要复练的表达
目标轮次、中文辅助开关、语速
完成判定条件
```
课程生成须返回完整课程/练习结构;对话单轮须返回英文回复、信息槽、原作答证据引用、任务完成建议,以及必要时的一句中文提示和结课重说句。客户端校验后才保存和判定,不能直接采信 AI 的完成标记。结构、语义审核、一次修复和内置模板回退遵循《开发与验收约定》第 2 节。
### 2.4 随时查词
教学内容中的英文词和已定义短语均可点开查意思。查词卡按“短语优先、单词其次”显示当前句义、中文释义、示范音、慢速播放和一个当前阶段可理解的例句;用户可手动加入复习。系统不要求用户先记住再允许查,也不因为查词把学习任务判错。
AI 对话中的词条必须来自当前允许语言范围,或是事先标为可解释的新词。动态内容没有审核词条时,只能显示明确标记的临时释义,不能自动变成核心词汇、课程内容或掌握证据。离线时仅查询内置/缓存词条。
教学态的查词、翻译、字幕和提示都算帮助完成;独立练习和出口评估中默认无查词入口。用户若要查词,先切换当前题到教学态,当前题不计独立证据,随后用新题补测。该行为是帮助路径,不是语言失败。
## 3. 词汇与句型掌握模型
词汇不以“看过一次”或“答对一次”为掌握。每个词和关键句型均经历五种状态:
| 状态 | 证据 | 系统动作 |
|---|---|---|
| 新学 | 第一次在课程中出现 | 当天再次在听、读、说中出现 |
| 认识 | 能从音频/英文中选出正确意思 | 安排中文/图片到英文的回忆 |
| 可回忆 | 看中文或图片能说/写出英文 | 安排句子填空、替换和改写 |
| 可使用 | 能在新句子或 AI 对话中正确使用 | 减少提示,安排变式场景 |
| 已掌握 | 间隔后仍能独立、正确使用 | 拉长复习间隔,保留偶尔抽查 |
### 3.1 掌握的最低规则
首次教学记录为“新学”。一次有效听辨/阅读识别成功可进入“认识”;一次无答案提示的说/写回忆成功可进入“可回忆”;在不同于教学原题的场景中无提示正确使用可进入“可使用”。升级必须保留前序的识别和回忆证据,不因一次对话跳过。
一个词或句型只有同时满足下列条件,才能标记为“已掌握”:
- 完成四个间隔复习检查点,每个检查点至少一次独立成功;正常计划为该词句首次完成正式教学后第 1、3、7、14 天。
- 有效证据覆盖听辨、回忆表达、情境使用三类任务;最近两次有效作答均无提示成功。
- 至少一次在不同情境中无句框主动使用;带“合理提示”完成仅算练习,不算该迁移证据。
每个词句单独保存首次正式教学日期:已获得释义/示范并提交至少一次该项应用练习(可带帮助,不要求答对)时登记,只登记一次;不等待整课或主题完成。仅看预热、查词或跳过教学不触发;主动收藏可以创建独立的待学提醒,但不启动四个掌握检查点。若该项当天有真实错误,补救与次日复习合并。
按本地日期排复习,正常四次成功的间隔为 1、2、4、7 天。逾期不补造结果:完成当前检查点后,下一检查点从实际完成日期按后续间隔安排,每天同一项最多推进一个检查点;漏学不清空历史。第四次后按 30 天抽查。提示后答对保持当前检查点,次日换题独立复测,不计失败。遗忘处理按下一节,不因单次错误清空历史或四个检查点。
每次作答区分独立成功、带帮助完成、语言错误、待确认和技术失败。记录听/说/读/写、提示、翻译、字幕、输入与转写修改状态;看答案后复述、同题立即重做不算独立间隔或迁移证据。文字可支持词句回忆/使用,但不能补齐口语证据;看字幕或翻译不能补齐独立听力。反馈来源为转写不确定或服务异常时,不进入失败计数。
例如 `coffee` 的掌握证据不只是知道中文意思;还应能听懂它、看到图片说出它,并在点餐中说出 `Id like a coffee, please.`
### 3.2 失败处理
- 一次有效语言失败:保留原状态和检查点,标记“需复核”,次日先换题无提示复核;需要时再提供示范。需复核项暂不计入首次阶段通过的词句门槛,但不撤销已取得的阶段通过记录。技术失败或 uncertain 不计语言失败。
- 复核成功:清除需复核标记。尚未掌握的词句按原检查点继续(满足到期时间与独立成功要求时可推进);原已掌握项 7 天后再抽查一次,成功后回到 30 天抽查。复核期间的辅助完成不清除标记,也不计第二次失败。
- 同一词句在不同任务的两次独立尝试均有语言失败,且中间没有独立成功:做简短识别/回忆/应用诊断,按最高仍有复测证据的能力回到“新学/认识/可回忆/可使用”;诊断不确定时保留需复核,不猜测。原检查点退回一个(最低为 0),保留全部历史,只补该点及后续间隔,不要求统一重走四次。
- 退回检查点后次日换题复核,成功补回一个检查点,再按后续 2/4/7 天间隔推进;从第 4 点退至第 3 点者补回第 4 点后,7 天再抽查一次,成功才恢复已掌握。恢复已掌握仍须满足三类任务覆盖、迁移与最近两次独立成功。
- 连续三次有效语言失败:插入约 2 分钟补救,使用当天复习/教学预算,不额外加时,并暂停该能力的新内容。计数只看有效独立作答,技术问题、求助和立即模仿均不累计。
### 3.3 可理解沟通标准
发音和听力的目标不是获得英音、美音或笼统的“发音分数”,而是在当前阶段的真实任务中稳定互相理解。
| 能力 | 达标定义 | 不作为判错依据 |
|---|---|---|
| 口语可理解度 | 听者能稳定识别姓名、地点、数字、需求、问题等关键信息,并完成任务 | 地区口音、轻微重音差异、与示范音不完全一致 |
| 听力可理解度 | 能听出场景中的主要意思和关键细节,并作出合适回应 | 第一次就听懂所有口音、所有自然语速材料 |
| 沟通补救 | 没听清时能请求重复、放慢或确认意思 | 不能一次听懂就视为失败 |
听力材料按以下难度推进:
```text
清晰慢速英语
→ 正常语速的清晰英语
→ 不同说话人和常见口音
→ 有少量背景声的真实情境
```
每个阶段都应教并练习沟通补救句:`Could you say that again?``Please speak slowly.``Do you mean ...?`。这类句子本身是听力能力的一部分。
### 3.4 发音反馈规则
| 识别到的情况 | 系统动作 |
|---|---|
| 语音识别可信度低、无可靠置信度或用户提出异议 | 显示转写并让用户确认、修改或重说;不记录为英语错误 |
| 有口音但表达可理解 | 接受答案;最多提供可选提示,不强制纠正 |
| 同一词反复被 ASR 错误转写 | 先排查噪声和识别问题;可提供用户自选通用练习,不据此诊断音素或降低掌握度 |
| 句子结构或词汇有误 | 作为表达错误反馈;不归为发音错误 |
一次反馈只处理一个点。`three/free` 可作为通用听辨、示范和回听练习,但界面须说明“识别可能有误,这不是发音诊断”。文字 AI 没有音频证据时不能断言用户把 /θ/ 发成 /f/;个性化音素诊断不在 M1。未编辑且确认的转写仅是受控口语的代理证据,不等于真人可理解度保证。
## 4. 阶段升级规则
已完成课数只用于回顾学习历史,不能决定升级。可以提前参加评估或延后升级,但只有门槛全部满足才记录阶段通过,不能手动跳级。入门轻量定位只推荐当前阶段内的教学起点,不替代出口评估。下一阶段内容未就绪时显示待提供,不自动生成未定义的主线。
### 4.1 A0 → A1
唯一执行标准见《A0 阶段标准》:固定 60 项核心分母,至少 48 项可使用或已掌握,其中至少 30 项已掌握;两次不同题组的听说读写评估分别通过,间隔至少 24 小时,最近一次在 30 天内。每项必需能力都要有证据,不以总平均分掩盖口语或听力缺口。
初次见面对话只是口语评估的一部分,还须验证姓名拼读、数字、物品、家人、星期/整点及沟通补救;阅读和写作单独评估。带提示、修改转写或识别失败的处理按标准执行。未通过则定向补练与换题复测,不限制追加课程数量。
### 4.2 A1 → A2
以下为 M2 的能力方向,尚不是可直接启用的 A1 自动升级规则。启用 A1 前须另行冻结核心清单、四技能题组和阈值;未冻结前禁用 A1→A2 自动升级:
- 能在点餐、购物、问路、交通、酒店等 8–10 个场景中完成目标;
- 能听懂慢速日常对话的主要信息;
- 能写 46 句连贯的短消息;
- 能完成约 3 分钟任务型 AI 对话;
- 能表达一个过去经历和一个简单计划;
- A1 的核心词句门槛以未来有版本的 A1 标准为准,不复用未定义分母的“约 80%”。
**出口任务**:完成一次旅行日:入住酒店、询问路线、点餐并处理一个简单问题。
### 4.3 A2 → B1、B1 → B2
| 升级 | 出口能力 | 建议任务 |
|---|---|---|
| A2 → B1 | 能讲经历、说明理由、处理多数日常突发情况 | 听 2–3 分钟慢速素材后复述;完成 5 分钟旅行问题解决对话;写一封短邮件 |
| B1 → B2 | 能理解自然语速主要内容、清楚支持观点、处理工作沟通 | 阅读/收听真实材料后总结和讨论;完成 10 分钟工作情境模拟;写结构化说明或意见 |
这两个阶段必须人工或通过高质量任务评估复核,不应只靠选择题自动升级。
## 5. 长期内容扩展
| 目标 | 新增内容 | 不可缺少的练习 |
|---|---|---|
| A2 | 过去时、将来计划、比较、建议、情绪、关系、问题解决 | 1–3 分钟叙述、短消息、生活变式对话 |
| B1 | 旅行问题、工作日常、观点与理由、故事复述 | 慢速真实音频/视频、段落写作、5 分钟讨论 |
| B2 | 复杂观点、会议、协商、跨文化表达、专业主题 | 自然语速材料、总结、辩护观点、10 分钟任务模拟 |
系统要成为从零到 B1/B2 的骨架,必须逐阶段增加“可理解输入”和“开放输出”的比例:A0/A1 以受控短句为主;A2 开始加入短文本和变式表达;B1/B2 以真实材料、观点表达、长对话和写作为主。
## 6. 个人使用检查
不需要留存指标。每周检查以下问题即可:
- 我能否无中文提示完成本周的核心 AI 场景?
- 我能否在新场景中替换并使用上周的关键句?
- 我是否能听懂本阶段材料的主要意思,而非只会背答案?
- 我在没听清时,能否用英语请求重复、放慢或确认意思?
- 我的关键信息是否能被稳定识别,而不因口音或偶尔的识别错误被误判?
- 我是否完成了阶段出口任务?
- 若没有完成,薄弱点是听、说、读、写还是词句回忆?
检查结果只用于调整下一周的复习量、课程速度和对话难度,不用于给自己贴“学得好/不好”的标签。
+162
View File
@@ -0,0 +1,162 @@
# M1 开发与验收约定
> 版本:1.02026-09-12|个人自用,Flutter 手机优先,支持 iPhone、Android、Mac。
> 本文件统一 M1 范围、数据契约、降级行为和验收。学习算法以《学习引擎规格》为准,A0 清单与评估以《A0 阶段标准》为准。其他文档不得另设升级门槛;调整时须同步更新并记录版本。
## 1. 冻结的范围
M1 是完整的 **A0 学习闭环**,不是全部 A0B2 产品:
- 10 节种子课程及必要的分段教学;每个有新内容的教学段先做课前词汇预热,再进入听、说、读、写和简短语法解释。
- 固定能力地图驱动的每日推荐、AI 变式和补救微课;课程总量不限。
- 课程内及独立入口的 A0 受控任务对话;文字输入、轮流录音、转写确认、TTS。
- 本地学习证据、掌握状态、到期复习、基础写作纠错与进度。
- A0 四技能出口评估、未通过补练、重新评估、通过状态。
- 课程、阅读与受控对话中的随时查词;短语优先、单词其次,提供释义、音频、例句和加入复习。
- AI 服务配置与连接测试、离线基础练习、中断恢复和安全的数据删除。
不包含:账号、社交、付费、留存运营、通知、自建业务后台、自动同步、导出/导入、自由主题聊天、实时全双工语音、精细发音诊断。导出/导入和 A1 课程在 M2;A2 及以上逐阶段扩展,不因文档列出目标而视为已经交付。
M1 通过 A0 后记录 `A0 已通过,A1 内容待提供`,可继续 A0 巩固;不生成未经定义的 A1 主线。只有阶段通过且下一阶段内容及评估规范已就绪时才允许进入。入门定位不能绕过阶段门槛。
## 2. 内容与生成契约
### 2.1 稳定身份与内容来源
阶段、能力、词句、课程、练习、素材均有稳定 ID 和版本。生成的变式有自己的 ID,并引用原能力及词句 ID,不能因措辞改变而产生新的核心掌握项。
内置种子内容需完成教学审校、答案校验及音频抽听后才标记 `approved`。外部网站和教材默认只作参考/外链;素材记录来源、许可或原创说明,不直接搬运。AI 生成内容标记来源为 AI,不能冒充人工审校。
### 2.2 最小课程结构(实现时转为有版本的 JSON Schema
| 对象 | 必填内容 | 约束 |
|---|---|---|
| 课程 | schemaVersion、lessonId、revision、stageVersion、source、status、abilityIds、prerequisiteIds、targetItemIds、receptiveChunks、newItemIds、previewItemIds、estimatedMinutes、tasks | status 为 draft / validated / approved / rejected;只有 validated 或 approved 可学习 |
| 练习 | taskId、skill、type、prompt、stimulus、targetItemIds、answerSpec、rubricId、supportPolicy、variantFamilyId | skill 为 listening / speaking / reading / writing;题型限定在客户端支持的模板中 |
| 答案 | 判定方式、参考答案、可接受变体、必需信息槽、排除条件、简短解释 | 选择题有唯一答案;开放题不以字符串完全相等作为正确条件 |
| 素材 | assetId、文本、语言、音频来源/生成状态、来源许可、校验摘要 | 听力题答案与实际播放文本一致;无音频时不伪装为听力题 |
| 对话 | scenarioId、角色、允许词句、目标信息槽、最少任务要求、最多用户轮数、收尾条件 | M1 通常 3–5 个用户回答轮;简单种子任务可更短,不能为凑轮次重复提问 |
每个可展示的英文词块要能关联 `LexiconEntry`:entryId、词形/短语、音标(可选)、词性(可选)、适合当前阶段的中文释义、TTS/课程音频、一个已学例句、关联能力/词句 ID、版本与来源。点击时优先匹配最长已定义短语,如 `Nice to meet you`,再匹配单词;专名、数字和标点不强制提供词典条目。释义应服务当前句义,避免塞入完整词典义项或未学复杂例句。
一个教学分段最多新增 8 个词汇项、2 个需要主动产出的句型;数字、字母、星期较多时拆段,每段约 10–15 分钟,完成种子主题不等于只用一天。复习段可以没有新词。固定短语按词块教学,所含功能词不再逐词重复计算,但必须提供整句释义和音频;新增理解用词块每段最多 2 个,超过则先增加准备段。
10–15 分钟为整段合计,已包含预热、应用练习、受控对话和独立尝试;后续词句步骤用于句中应用而非重复词卡。每日 10/20/30 分钟的分配、积压阈值、复核降级均以《学习引擎规格》2.1、2.2a、3.2 为唯一执行规则。
每段必须标注 independentTaskIds 和各技能的完成状态;独立任务可复用对话最后一轮或写作题,时长约 30–90 秒。撤掉帮助后换内容值,恢复帮助则标记辅助,不当场无限循环;跳过保持待做。完整句写作从第 5 课逐步练,第 6–10 课落实自由输出,不用填槽证据代替出口能力。种子主题四技能机会映射见课程包。
有新增内容的教学段在正式任务前必须定义 `previewItemIds`:只包含该段会遇到的新增词汇和理解词块,通常 3–6 项,最多不超过本段的新词/词块预算。每项有词条、音频、当前句义与一项轻量匹配/听辨;可逐项重放、查词、跳过或回看。预热完成只记为 `exposed`,不产生掌握成功、失败或复习到期日;没有新增内容的复习段不显示预热。
对话额外新词指本段与已学内容之外的词:每轮最多 1 个、整段对话最多 2 个,先教学再使用;出口评估为 0。额外新词仅作扩展复习,不自动加入阶段核心分母。
### 2.3 每日生成流程
1. 本地规则先选到期复习、未完成任务或已满足前置条件的能力,给 AI 明确的目标 ID;没有薄弱项时正常复习或选下一个能力,不硬造错误。
2. 请求包含阶段版本、能力及词句白名单、理解用词块、个人薄弱项、提示策略、题型和新词预算。不发送真实电话号码;姓名、地点可使用虚拟资料。
3. 输出完整课程/练习对象,不能只返回一段聊天文字。对话单轮返回 reply、已提取信息槽、证据引用、完成建议和可选的一条反馈。
4. 本地检查结构、ID 引用、前置关系、必填答案、词句预算、轮次;专名与词形变化使用明示的允许表,不用简单英文词数误判超纲。
5. 用独立审核请求检查自然度、答案与材料一致性、难度及无提示可解性;可以同一模型,但输入不包含生成时的推理。审核失败或不确定不发布。AI 审核并非正确性保证,提供“内容有问题”入口,标记后隔离相关题目与评分证据并重新计算受影响状态。
6. 校验通过后保存版本,再展示。每个生成任务最多自动修复 1 次;仍失败则用同能力已审核的内置/缓存模板,不无限重试。若无可用变式,提供已审核原题复习并说明不是新迁移证据。
生成、审核、转写失败均不得改变掌握度。AI 只能提出判定,客户端根据证据和规则更新状态;不采信模型单独返回的“已掌握/可以升级”。
### 2.4 判题边界
封闭题优先本地判定。开放题返回 correct / incorrect / uncertain、原始作答证据片段、缺失信息和可选改进,不得补写用户未表达的信息。用户说出有效同义表达应接受;轻微大小写或标点不阻断表达成功。检测到无法确认的答案或用户质疑时记 uncertain,安排不同题重测,不直接降级或升级。
课程完成只表示完成教学任务,可以带提示或文字完成;词句掌握和阶段通过另按有效证据计算。离线自由写作可以保存为 pending,不假装已获得 AI 纠错。
### 2.5 查词与学习证据
教学模式中,课程正文、AI 消息、阅读材料、听力文字稿和纠错示例均支持点词/点短语;查词卡显示中文意思、原句、播放/慢放、一个简短例句,以及“加入复习”。如果当前内容来自动态生成,优先使用本地已审核词条;缺失时可请求文字 AI 生成**临时释义**,标记“待审核”,不自动加入核心清单或掌握计算。断网时只显示本地词条或“暂时无法查询”,不编造解释。
教学中点词、看释义或翻译不会失败,也不阻止完成课程;相应 attempt 记录 `lookupEntryIds``lookupCount` 与是否查看整句翻译。用户主动“加入复习”创建低优先级认识/回忆练习,不能直接标记已掌握;同一词条一天最多自动加入一次,避免误触堆积队列。
独立练习与出口评估默认隐藏查词、整句翻译、词库和句框。用户仍可选择“先查词再练习”,系统将当前题切换为教学态,清楚提示“本题不计独立听/读/说/写证据”,然后提供另一等价题补测;不把查词行为记为语言错误。查看词条本身不等于看答案,正式评估中不提供入口以避免该边界含混。
## 3. 本地数据最小约定
| 实体 | 必须保存的信息 |
|---|---|
| StageDefinition / AbilityNode | 阶段版本、核心项 ID、能力 ID、前置条件、出口规则版本、内容是否就绪 |
| Lesson / Task / Asset | 上述内容契约、生成/审核来源、版本、可用状态、缓存引用 |
| AttemptEvidence | 唯一 attemptId、taskId/版本、目标词句、技能、原始作答、输入方式、转写原文/修改后文本、确认/修改标记、提示/翻译/字幕/重放/查词词条次数、结果、判定来源、时间、variantFamilyId |
| PreviewExposure | lesson/段版本、词条 ID、首次/最后查看时间、播放次数、是否跳过;只用于恢复和推荐回看,不计掌握 |
| LexiconEntry / SavedWord | 稳定词块 ID、当前释义/音频/例句版本、来源与审核状态;用户收藏时间、复习状态、是否核心项 |
| MasteryItem | 稳定词句 ID、状态、首次正式教学时间、分技能证据、有效成功/失败引用、检查点、到期时间、需复核标记与复核阶段;必须能从证据重算 |
| StudySession / ConversationSession | 当前步骤、已提交轮次、信息槽、请求 ID、暂停/待反馈/完成状态;原始录音仅引用本机路径 |
| StageAssessment | 评估版本、题目版本、四技能结果与证据、提示情况、pending / passed / needsPractice / invalidated、补练目标 |
| ProviderConfiguration | 地址、模型、能力测试结果和时间;密钥仅存安全存储,不写入数据库日志 |
相同 attemptId 的重复提交只入账一次;证据、掌握状态和复习更新使用一次本地事务。网络重试不得产生多份学习成功。题目升级保留旧作答对应的旧版本;无效内容导致的结果失效需显示原因,而非显示“你退步了”。
一题多个目标时记录每个目标的证据片段、技能和结果,只对实际证明的目标更新,不把整句正确广播给所有词。每天同词句最多推进一次间隔。首次正式教学时间在释义/示范加首次应用尝试后登记;预热、查词、收藏及整课结束均不能重置它。复核中的词句暂不计入首次阶段升级门槛,既有阶段通过记录保留。
## 4. 语音边界和异常恢复
设备原生 STT/TTS 是否离线可用取决于设备、系统、语言包及权限,启动时探测,不作跨平台保证。原生识别也可能联网,首次使用说明数据去向;云端转写仅在用户启用后发送录音。M1 默认代理只接收文字。
| 情况 | 行为 | 学习证据 |
|---|---|---|
| 无网络/AI 未配置 | 内置课程、缓存/本机可用音频、本地答案题和模板复习;动态生成暂停 | 本地可判定题正常记账,开放题 pending |
| STT 无权限/不可用 | 保留录音回听(若录音权限可用),提供文字练习或稍后重试 | 文字不能替代口语达标,缺口为待评估而非失败 |
| TTS/音频不可用 | 可继续阅读/写作;听力题留待补做 | 不把看文本答题计为听力 |
| 转写无置信度、与回答目标不符或用户质疑 | 显示“我没有完全听清”,确认/修改/重说;无可靠置信度时统一先确认 | 修改后仅记文字表达;有争议的原语音不判错 |
| 超时/断网/429 | 保留已提交内容和当前轮;提示手动重试,429 按服务等待时间限制重试 | pending,不扣掌握度 |
| 401/403/模型不存在 | 引导修正配置,停止自动重试 | 不修改学习结果 |
| 非法 JSON/超纲回答 | 不展示为正式题目或计分结果;最多修复一次,转缓存模板 | 无合格结果则 pending |
| 退出/来电/重复点击 | 停止录音并保存步骤,返回时“继续上次练习”;请求结果按 ID 去重 | 不重复计分 |
默认单次 AI 请求 30 秒超时;用户可取消。切换服务由用户明确操作,不自动切换到另一付费服务。连接测试依次检查地址/鉴权、选定模型短文本、结构化返回;流式可选,失败时可使用非流式。测试会发送极短测试文本且可能计费,界面事先说明;兼容地址不意味着语音能力也兼容。
手机的 localhost 不是 Mac;设置页提供局域网连通性说明。远程连接使用 HTTPS,不通过不可信明文地址发送 Token,不关闭证书验证。测试和错误日志隐藏 Token 与敏感内容。
发音只提供示范、慢放、回听及可选通用提示。重复 ASR 错误不能诊断音素;转写准确也不等于已证明真人都能听懂。M1 的口语证据为受控场景下的有限代理指标,个性化音素诊断和更高阶段真人可理解度复核后续另行验证。
## 5. 开发顺序和验收样例
先完成一条纵向闭环,再扩展全部种子内容与平台;以下是待开发验收要求,不代表已经测试通过。
1. 本地一课:听力 → 跟读 → 阅读 → 写作 → 本地记录,中断后可继续。
2. 接入文字受控对话、结构校验、开放答案反馈和失败降级。
3. 接入录音、TTS、STT、转写确认和分技能证据。
4. 接入每日推荐、间隔复习、生成变式与 A0 四技能出口评估。
5. 完成全部种子段和评估题组的教学/音频审校;iPhone、Android 真机后完成 Mac 同流程验收。
| 编号 | 场景 | 必须结果 |
|---|---|---|
| AC01 | 完成 10 节但掌握不足 | 不解锁 A1,推荐当前阶段补练 |
| AC02 | 新生成 100 个扩展词 | A0 核心分母仍为 60,不覆盖旧核心 ID |
| AC03 | 看翻译答对听力、修改转写后答对 | 可完成练习,不记独立听力/口语成功 |
| AC04 | AI 声称通过但缺少必需信息 | 客户端不通过;显示待补信息或待确认 |
| AC05 | 缺一次复习/一周未打开 | 逾期项保留,不补造成功、不集中要求补做四次 |
| AC06 | 网络或识别连续失败 | 不因技术失败降级;可以本地练习且保留待评估 |
| AC07 | 相同题立即重做、重复提交请求 | 不增加独立间隔/迁移证据,不重复记账 |
| AC08 | 生成非法格式、错答案、超纲题 | 拒绝该结果,修复最多一次后使用合格模板 |
| AC09 | A0 门槛及两次四技能评估都满足 | 记录 A0 通过;M1 显示 A1 待提供,不编造 A1 课程 |
| AC10 | 口语待评估但文字全部通过 | 不标记完整阶段通过,给出补测入口 |
| AC11 | 清空数据 | 二次确认具体范围;仅删除所选本机记录,密钥单独管理 |
| AC12 | 手机和 Mac 分别练习 | 各自进度独立,界面不暗示已同步 |
| AC13 | 教学中点 `from``Nice to meet you` | 优先显示短语或词块的中文释义、音频、例句和加入复习;不退出当前学习步骤 |
| AC14 | 评估时尝试查词 | 提示先转为练习;当前题不产生独立证据,并提供等价题补测 |
| AC15 | 开始含新词的教学段 | 先显示仅覆盖本段的课前词汇预热;预热后进入课程,跳过不阻止学习 |
| AC16 | 只看完词汇预热 | 保存观看位置,可回看;不增加掌握度、不创建到期复习、不让课程自动完成 |
| AC17 | 10 分钟内未完成课程 | 保存下一步骤,预热和对话不重复计时;未做技能不自动完成 |
| AC18 | 结尾独立尝试时查词 | 允许继续,记辅助;下一次换题,不增加本次独立证据 |
| AC19 | 一题正确说 I like tea | 分别检查 tea 与 I like 的表达证据,不推断其他词或听力成功 |
| AC20 | 复习积压超过阈值 | 暂停推荐新段,交替照顾最早到期和近期错误,总预算不增加 |
| AC21 | 主题分三天完成 | 各词句从自己的首次正式教学日排期,不等主题结课 |
| AC22 | 已掌握项一次答错后复核成功 | 保留历史与状态,先 7 天抽查,再回 30 天;不重走四次 |
| AC23 | 第 10 课已说姓名地点喜好但未反问 | 留出反问机会;五项齐全才成功,上限缺项记待补练 |
## 6. 文档索引
本轮学习流程修订:统一教学总预算,增加撤提示的独立尝试,落实写作递进;复习改为按时间选任务并逐词句取证,按词句首次正式教学日起算;单次遗忘先复核;第 10 课统一五项完成条件。本轮修改仅涉及 Markdown 规格与文字线框,HTML 原型和应用实现尚未同步这些变更。
- [产品范围](PRODUCT-PRD.md)
- [学习引擎](LEARNING-ENGINE-SPEC.md)
- [A0 阶段标准](A0-STAGE-STANDARD.md)
- [种子课程](A0-COURSE-PACK.md)
- [AI 对话](AI-CONVERSATION-PRD.md)
- [技术架构](TECHNICAL-ARCHITECTURE.md)
- [页面交互](M1-WIREFRAMES.md)与[原型说明](PROTOTYPE-DESIGN-SPEC.md)
+53
View File
@@ -0,0 +1,53 @@
# M1 人工音频与真机验收清单
> 状态:待实际设备与人工听者执行。自动测试、模拟器和 Debug 构建不能替代本清单。
## 执行原则
- 每个平台用真实麦克风、扬声器或耳机完成;记录系统版本、设备型号、日期和异常。
- 测试时不要输入真实电话号码、住址或其他敏感资料。
- 听力抽听由英语能力足以判断 A0 清晰度的人完成;TTS 清晰可懂不等于真人发音审校。
- 原始录音仅留在测试设备;验收后按用户设置删除。
## A. 种子课程与评估音频抽听
对 10 节种子课程的每个新词块、每个听力播放按钮、两套 A0 评估的每个听力题逐项确认:
- 播放文本与显示的参考文本/正确答案一致。
- 正常速度与慢速都能完整播放,无截断、重叠或明显失真。
- 音量在设备默认音量下清楚;英语重音与停顿适合 A0 初学者。
- 不因播放失败把阅读文本当作听力完成。
将发现的问题记录为:课程或评估题 ID、设备、问题描述、复现步骤、是否已替换/复听通过。
## B. 三端学习闭环
在 iPhone、Android、macOS 各完成一次以下流程:
1. 新安装后完成引导,开始 A0-01,验证预热不创建掌握或复习项。
2. 完成听、跟读、阅读、写作、受控对话和独立尝试;中途关闭应用后继续,确认草稿与步骤恢复。
3. 授予与拒绝麦克风/语音识别权限各一次;拒绝时能文字练习或保留待评估,不能算语言失败。
4. 录音、回听、删除各一次;关闭“保存录音”后离开步骤,确认录音已删除。
5. 配置一个 HTTPS AI 服务,检查连接、受控对话、动态补练、临时释义;断网时确认本地课程可用且不编造结果。
6. 在补练中用语音输入:确认转写、编辑转写、退出并恢复草稿各一次,核对输入方式记录。
7. 完成/模拟到期复习、一次失败复核、AI 内容举报隔离,以及 A0 评估口语待评估路径。
## C. 发音可理解度抽样
M1 不提供音素级诊断。可用以下人工抽样确认“受控情境下可理解”:
- 学习者不看文字录下 A0-10 的姓名、地点、喜好、联系方式与反问。
- 两位独立英语听者分别听录音,写下自己听到的关键信息,不看参考答案。
- 只有关键信息都能被稳定复述时,记录为“受控场景可理解”;否则只给出示范/慢放/回听建议,不把 ASR 错误诊断为发音错误。
本项不等同于更高阶段真人交流能力,也不改变 A0 自动升级门槛。
## 当前执行记录(非最终签收)
| 日期 | 范围 | 已验证证据 | 结论 / 未覆盖项 |
| --- | --- | --- | --- |
| 2026-09-14 | 自动回归 | `flutter analyze` 无问题;`flutter test` 70 项通过 | 覆盖状态恢复、证据重算、动态内容判定等代码路径;不代表真实设备能力。 |
| 2026-09-14 | 三端可构建性 | `flutter build macos --debug``flutter build apk --debug``flutter build ios --debug --no-codesign` 均成功 | iOS 产物仍需签名后才能部署;构建成功不等于真机验收。 |
| 2026-09-14 | macOS 桌面界面 | 已启动 Debug 应用,并通过 Computer Use 读取第 1 课写作页面、文本框与操作按钮 | 只完成界面可访问性确认;TTS、录音、STT 的真实输出和整课闭环仍待人工执行。 |
| 2026-09-14 | iPad 连接 | Flutter 发现无线 iPad;部署前检查提示设备需解锁、开启 Developer Mode,并通过 USB 或同一局域网连接 | 尚未部署,iPad 验收未开始。 |
| 待执行 | Android 真机、人工音频抽听、两位听者可理解度抽样 | 无 | 不应以自动测试或系统 TTS 的存在替代这些验收。 |
+324
View File
@@ -0,0 +1,324 @@
# M1 页面线框与交互规格
本文件定义首版的核心页面和状态。视觉参照请见同一交付中的页面线框;本文件用于产品、设计和研发对齐行为与验收。
完整页面清单、视觉规则和关键流转见 [M1 全部原型设计说明](PROTOTYPE-DESIGN-SPEC.md)。
M1 范围和跨页面行为以 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准;评估页面执行 [A0 阶段标准](A0-STAGE-STANDARD.md)。本次仅更新文档线框,不代表 HTML 或应用界面已同步实现。
## M1 范围
M1 是手机优先的 A0 单人学习闭环:10 节种子课及分段、听说读写、受控 AI 对话、变式/补救、次日复习、掌握证据、进度与四技能出口评估。课程总量不限,未通过不升级;通过后 A1 内容未就绪则显示待提供。每台设备独立使用;不含登录、社交、付费、通知、增长、自动同步或导出/导入。
## 全局规则
- 界面默认中文,英文只出现在学习材料、示范句和对话中。
- 每个页面最多一个高强调主按钮;离开流程的操作为低强调按钮或文字链接。
- 教学音频提供文字、重放和慢放;课程、阅读与对话中的英文词或短语可点开查词,关闭后回到原步骤。独立听力/评估先隐藏文字,查看后标记辅助,播放次数遵循评估标准。录音失败可切换文字学习,但不能记为口语达标。
- 你可中断课程或对话,回到首页后显示“继续上次练习”。
- 进度以“能完成什么”展示,不只显示词汇数量或分数。
- 学习进度、复习队列与设置保存在本机;如需换设备续学,后续提供加密导出/导入,不需要账号系统或多人数据模型。
## P01 欢迎与目标
```text
┌──────────────────────────┐
│ 开口英语 │
│ 每天 20 分钟,说出能用的英语 │
│ │
│ 你的主要目标是? │
│ ○ 日常生活 ○ 旅行 ○ 工作起步│
│ │
│ 每天可以学习多久? │
│ [10 分钟] [20 分钟] [30 分钟]│
│ │
│ [继续] │
└──────────────────────────┘
```
**输入**:目标、每日时长。
**输出**:保存为学习计划参数;进入 P02。
**验收**:全程不超过 2 次选择;跳过不影响后续使用。
## P02 起点定位
```text
┌──────────────────────────┐
│ 从哪里开始? 第 1 / 3 步 │
│ │
│ ○ 完全零基础 │
│ 从你好、自我介绍开始 │
│ ○ 能说一点 │
│ ○ 能简单对话 │
│ │
│ [开始 3 分钟定位] │
│ [直接从第一课开始] │
└──────────────────────────┘
```
**测评流程**:听句选义(1 题)→ 读 `Hello. My name is ...`1 题,可跳过)→ 回答 `What is your name?`1 题,可文字输入)。
**结果页**:显示“建议从 A0 第 X 课开始”,用一句中文说明原因;提供“从更简单内容开始”和“开始今天学习”。
**验收**:不出现百分制、排名、失败字样;录音权限被拒后自动显示文字作答。
轻量定位不生成阶段通过记录、不解锁 A1。已会部分内容的用户可从进度页申请正式 A0 评估。
## P03 首页
```text
┌──────────────────────────┐
│ 早上好,Shen │
│ 今天,说 3 句英语。 │
│ [当前掌握 ███░░░░░░░] │
│ │
│ 第 2 课 · 拼读姓名 │
│ 12 分钟 · 先认识 4 个词 · 听说读写│
│ [开始今天的学习] │
│ │
│ 快速练习 · AI 对话 │
│ 初次见面 · 3 分钟 │
│ [开始对话] │
│ │
│ 首页 学习 对话 复习 我的│
└──────────────────────────┘
```
**规则**:只显示一个主任务,按当日时间预算优先复习,再续学/新能力;用户可恢复中断会话。分别显示复习任务数和覆盖词句数,不设每天五个词的硬上限。积压超过学习引擎阈值时显示“今天先巩固,明天再安排新内容”,允许用户自主选择新课。
**验收**:首次用户从首页开始课程不超过 1 次点击;学习完成后首页立即更新下一任务。
## P04a 课前词汇预热
```text
┌──────────────────────────┐
│ 第 2 课 · 先认识今天的词 │
│ ●○○○ 约 2 分钟 │
│ │
│ 今天会遇到 4 个词和词块 │
│ │
│ spell │
│ /spel/ · 拼写 │
│ 🔊 [播放] [慢速] │
│ How do you spell that? │
│ 那个怎么拼? │
│ │
│ [认识了,下一个] │
│ [跳过,直接进入课程] │
└──────────────────────────┘
```
**规则**:只列出当前教学段将出现的新增词和理解词块,通常 3–6 项;按词条逐项听、看当前句义、查看短例句,并至少做一次轻量匹配或听辨。可重放、查词、跳过、结束后回看;预热不出现长词表或完整词典。
**证据**:仅保存“已看过/已播放/已跳过”,不增加掌握度、不产生错误或到期复习。预热后仍要在课程中独立听懂、回忆和使用。
**验收**:新教学段从首页进入后先到此页;没有新语言或恢复已开始课程时不强制显示。用户跳过仍可进入课程,之后能在课程目录回看。
预热计入教学段总时长;后续直接在句中练习,仅在需要时回看词卡。到每日预算边界显示“今天先到这里 / 继续练”,保存下一步骤,不自动标记课程完成。
## P04 课程页(通用)
```text
┌──────────────────────────┐
│ 第 4 课 · 数字 │
│ ●●○○○ 4 分钟 │
│ │
│ 听一听 │
│ 🔊 My number is 138. │
│ [慢速] [重说] │
│ │
│ 这句话是什么意思? │
│ ○ 我的号码是 138。 │
│ ○ 我有 138 本书。 │
│ │
│ [检查] │
└──────────────────────────┘
```
**组件**:课前词汇预热、步骤进度、情境图/音频、句中应用、听力题、跟读录音、短阅读、写作题、AI 对话入口、结尾独立尝试。
**规则**:每教学段最多 8 个新词汇、2 个主动句型、2 个理解用词块;超出则拆段。每个关键句至少复现两次。点词/点短语打开查词卡,短语优先,显示当前句义、中文释义、音频、慢放、已学例句和“加入复习”。听力示意中的字幕仅代表教学态,独立答题态隐藏。
**验收**:用户可在任一步重放、慢放或查看中文;保存中断位置。
## P05 AI 情境对话
P04b 的独立尝试可在本页最后一轮执行,避免重复进入另一整套测试。
```text
┌──────────────────────────┐
│ 初次见面 · 2 / 4 │
│ 目标:说姓名和来自哪里 │
│ │
│ AIWhats your name? │
│ 我:My name is Shen. │
│ AIWhere are you <点词>from</点词>? │
│ │
│ [提示] [慢一点] [翻译] [重说] │
│ │
│ [按住说话 🎙] [输入文字] │
└──────────────────────────┘
```
**输入**:语音(默认)或文字;语音转写发送前可编辑。低可信度时显示“我没有完全听清”,并提供“确认转写 / 修改文字 / 重新说”,不直接判错。
**AI 行为**:只问当前等级能理解的单一问题,记住已回答信息,优先推进任务;只有无法理解、关键任务阻塞或用户点“提示”时才简短辅助。AI 消息中可点英文词/短语打开查词卡;关闭后保持同一会话轮次,不重新生成或重复提问。
**验收**:完成 3–5 轮不超过 5 分钟;语音识别失败时不丢失上下文,也不记录为发音错误。
## P06 课程/对话总结
```text
┌──────────────────────────┐
│ 你完成了自我介绍! │
│ 你说出了姓名和来自哪里。 │
│ │
│ ✓ 做得好 │
│ My name is Shen. │
│ → 下次这样说 │
│ Im from Hong Kong. │
│ │
│ [录音重说] │
│ [明天复习这句话] │
│ [回到首页] │
└──────────────────────────┘
```
**规则**:总结显示教学完成与独立尝试结果的区别:独立完成/帮助下完成/待做/待确认。需要补救的目标合并到次日复习,已稳定项目保留原间隔,不因总结重说统一改成明日到期。
**验收**:每次自由输出至少生成一个正向反馈与一个可复习项目;无错误时给一句可替换表达,而非强行挑错。
## P07 复习页
```text
┌──────────────────────────┐
│ 今天复习 · 3 项 │
│ │
│ 昨天你在问候时练过: │
│ AIHow are you? │
│ │
│ [按住说话 🎙] [查看提示] │
│ │
│ 完成后:下一项 1 / 3 │
└──────────────────────────┘
```
**规则**:以各词句首次完成正式教学为起点安排间隔,不等待整课结束。任务可覆盖多个词句但逐项记证据,预算和队列公平性按学习引擎;单次遗忘显示“下次换题确认”,复核后才决定降级。技术失败不算错误,离线切换审核模板。
**验收**:只移出本次已有效完成的到期项,其余仍保留;辅助完成次日换题,不因完成五项就清空队列。
## P08 我的进步
```text
┌──────────────────────────┐
│ 我的进步 │
│ 当前:A0 起步 │
│ 已能做到 │
│ ✓ 介绍姓名 │
│ ✓ 说出来自哪里 │
│ ○ 拼读电话号码 │
│ │
│ 已完成 3 个核心任务 │
│ [调整每日学习时间] │
└──────────────────────────┘
```
**规则**:能力清单由分技能有效证据驱动,课程完成不自动等于掌握。显示已练习、可使用、已掌握与待评估区别,不把词数设为唯一成就。
**验收**:用户能看到当前等级、已能完成的任务、接下来要练的能力和每日时间设置。
设置页还应提供“AI 对话服务”入口,供个人使用时选择 Gemini API、OpenAI API 或 OpenAI 兼容反代。兼容反代仅显示 Base URL、Token、模型名与“仅文字对话”能力状态;不在常规学习流程中暴露网页登录或订阅凭据。
## 页面流转
```text
P01 → P02 → P03
P03 → P07 到期复习 → 教学/续学 → P06 总结 → P03
P03 → P08
P03 → P04a 课前词汇预热 → P04 课程(有新语言时)
P04:听力 → 跟读 → P09 阅读 → 写作 → P05 对话 → P04b 独立尝试 → P06
P04 / P05 / P09 → P09a 查词卡 → 返回原步骤
P08 → P10 评估准备 → 四技能任务 → P11 结果 → P12 补练 → 换题补测
设置 → P13 服务配置 → 连接测试 → 保存
```
## P09 阅读(课程子页)
```text
第 6 课 · 阅读
Mia: Where are you from?
Shen: I'm from Hong Kong.
Shen 来自哪里?
○ Hong Kong ○ China
[检查答案] [查看翻译]
```
保留英文短文和题目,选答案后反馈并进入写作。教学可点词/翻译;正式评估不提供释义,使用后当前题转练习。记录 reading,不把点击朗读算听力成功。
## P04b 独立尝试(课程或对话子状态)
```text
试着自己说 / 写一次 · 约 1 分钟
请介绍你来自哪里。
[录音回答] 或 [输入完整句子]
[需要帮助] [稍后继续]
```
不给英文句框、词库或范句;听力只播问题,阅读仍保留英文材料。每次选择一种技能,不能用打字算口语。换已教范围内的图片或任务值;独立成功才产生该技能证据。需要帮助则恢复句框/查词、标记辅助完成并结束该次尝试,后续换题再练;稍后继续则保留待做。该步骤包括在教学预算内,可复用对话结尾;各主题四技能机会按课程包安排。
## P09a 查词卡(课程/阅读/对话子状态)
```text
┌──────────────────────────┐
│ from │
│ /frʌm/ · 介词 │
│ 来自;从…… │
│ │
│ 🔊 from [慢速] │
│ I'm from Hong Kong. │
│ 我来自香港。 │
│ │
│ [加入复习] │
│ [关闭,回到对话] │
└──────────────────────────┘
```
以底部卡或弹层展示,不跳转或清空课程/对话状态。优先匹配完整短语,例如 `Nice to meet you`,找不到时再显示单词;专名、数字或离线缺失词条显示清楚说明,不编造释义。教学态允许查词并记录为帮助完成;评估态不显示入口,若用户提出查词,先说明会转为教学练习并换题补测。
## P10 阶段评估准备
```text
A0 能力检查 · 题组 1 / 2
核心可使用及以上 48 / 60
其中已掌握 30 / 60
听 / 说 / 读 / 写 · 约 12–18 分钟,可分段
不看翻译和句框;语音识别不清不会扣分
[开始检查] [先补练]
```
数字是展示样例,不是用户已有记录。未达核心门槛仍可参加评估,但不能仅凭评估升级。检查音频/麦克风能力;不可用可做其他技能,口语或听力显示待评估。提供明确的播放、提示限制;最多 7 天内完成当前题组。退出保存有效步骤,不提前展示未答题答案。
## P11 评估结果
```text
A0 检查结果
听 ✓ 说 待评估 读 ✓ 写 需练习
写作:再练“我来自哪里”
口语:上次转写有修改,需要换题录音
[开始补练] [查看证据]
```
区分 needsPractice、pending、passed,不将技术失败显示成语言失败。第二题组只在第一组完成至少 24 小时后开始。两组通过仍需检查核心门槛和证据有效期。全部满足显示“A0 已通过”;M1 显示“A1 内容待提供”和“继续 A0 巩固”,没有可强制解锁的按钮。四技能、核心门槛和题组进度分别可查看。
## P12 定向补练与补测
列出未通过能力、原因和一个主任务;从已有合格模板或经审核的变式选题。完成至少一次相关补练后提供“换题再检查”,7 天题组窗口内保留其他已通过技能。技术失败可直接恢复或换题,不强制补练。同原题立即重做只算练习。
## P13 AI 服务与通用错误状态
设置显示提供商、Base URL、模型、掩码 Token、“测试连接”和能力结果。测试前提示会发送测试文本且可能计费;成功后保存,不自动把文字代理用于语音。无流式但非流式可用不算整体失败;鉴权失败、地址不可达、模型不支持、结构异常分别给出中文原因。手机访问 Mac 时说明 localhost 限制;不自动切换付费服务。
| 状态 | 展示与操作 | 保留内容 |
|---|---|---|
| 无到期复习 | 今天暂无到期项目,可自选巩固/回首页 | 不伪造待练项 |
| AI 未配置/断网 | 本地练习可用;配置服务/稍后重试 | 当前步骤与待反馈答案 |
| 麦克风拒绝 | 前往系统设置/文字练习 | 口语待评估,不判错 |
| 无可用音频 | 先做阅读写作/稍后补听 | 听力待评估 |
| 生成或判题异常 | 使用本地练习/重试;可举报内容 | 不写入假成功或语言失败 |
| 中断返回 | 继续上次练习/返回首页 | 最近已提交步骤,不自动重复提交 |
| 内容有问题 | 标记题目/换题 | 隔离相关证据并说明状态重算原因 |
| 删除记录 | 显示录音/会话/学习进度具体范围并二次确认 | 未选择数据与密钥不受影响 |
转写确认页始终区分未修改确认与编辑后提交,后者显示“本次作为文字练习;口语可稍后补做”。
## P14 通用发音练习(跟读子状态)
显示示范音、慢放、自己的录音、回听和可选的一条动作提示;标题为“对比听一听”,不显示“你的 /θ/ 错了”。用户可重录或返回,不因跳过影响升级;真实口语证据仍从独立任务采集。
+336
View File
@@ -0,0 +1,336 @@
# 开口英语(暂定名)首版产品 PRD
> 版本:0.22026-09-12
> 使用方式:个人自用、手机优先,支持 iPhone、Android 与 Mac 的中文英语学习工具
> 学习目标:按可用时间练习,逐步把英语用在生活中,追求稳定听懂、稳定被听懂;不承诺固定周数达标。
## 1. 产品概述
### 1.1 要解决的问题
零基础成人通常不是缺资料,而是缺少三件事:知道自己该从哪里开始、每天有一条明确的学习路径,以及有机会把输入转成开口和写作。传统资料站提供大量音频、文章和语法说明,但用户容易收藏后放弃,也难获得个人化纠错。
本产品提供按能力分级的短课。每节课围绕一个生活任务,完成“听懂—模仿—开口—阅读—写出—复习”闭环。AI 仅在用户实际输出后进行简短、可执行的反馈。
发音目标是清楚、可理解、可完成任务,而非模仿特定地区口音;听力目标是理解当前阶段场景中的主要意思与关键细节,并能在没听清时继续沟通。
每日内容生成、词句掌握判定、阶段出口任务与通向 B1/B2 的内容扩展,统一以 [学习引擎规格](LEARNING-ENGINE-SPEC.md) 为准。
M1 范围、内容数据和异常验收以 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准;A0 可执行门槛见 [A0 阶段标准](A0-STAGE-STANDARD.md)。下文 A1 场景是后续方向,不属于 M1。
### 1.2 当前目标
- M1 先建立自我介绍、简单问候和 A0 信息交流能力;掌握后再进入购物/点餐等 A1 内容,不以 4 周或固定课数解锁。
- 让你在每个阶段通过核心课、变式任务和补救练习,逐步建立开口习惯;不以固定总课数作为目标。
- 让每次练习结束时都能明确知道自己会说什么、下一步练什么。
### 1.3 当前不做
- 不以雅思、托福、四六级等考试提分为目标。
- 不在首版提供高阶新闻阅读、商务谈判或开放社交社区。
- 不把整套教材或外部网站内容直接搬入应用。
- 不做登录注册、付费、消息推送、排行榜、多人协作、增长分析或上线运营。
- 当前支持 iPhone、Android 与 MacWeb、多人共享和自动跨设备同步不在 M1 范围。
客户端、数据同步和 AI 调用的实现边界见 [技术架构](TECHNICAL-ARCHITECTURE.md)。
## 2. 用户与使用场景
| 用户 | 现状 | 需求 |
|---|---|---|
| 零基础职场成人 | 认识少量单词,羞于开口 | 从最简单且有用的句子开始,得到耐心纠错 |
| 有基础但遗忘的成人 | 学过语法,听说困难 | 快速定位起点,恢复日常对话能力 |
| 旅行/生活需求者 | 有明确近期出行或生活任务 | 在出发前完成点餐、问路、入住等演练 |
首版默认用户为第一类。界面语言、解释和纠错均为简体中文;英语内容保持真实、自然、短小。
## 3. 能力等级与升级规则
| 等级 | 名称 | 核心能力 | 进入条件 |
|---|---|---|---|
| A0 | 起步 | 认识常用字母/声音,能说极短句 | 新用户默认起点或测评结果 |
| A1 | 生存 | 用简单句完成固定生活互动 | A0 通过且 A1 内容就绪;M1 显示待提供 |
| A2 | 日常 | 描述日常、计划与简单经历 | 首版仅显示“即将推出” |
### 3.1 入门测评
测评控制在约 3 分钟,共 3 项:
1. 1 题听句选义;
2. 1 题朗读 `Hello. My name is ...`,可跳过;
3. 1 题回答 `What is your name?`,可用文字或录音。
结果只给出建议起点和原因,不给“失败”标签。用户可随时选择“从更简单内容开始”。语音题不作为硬性分级依据,避免麦克风环境造成误判。
### 3.2 阶段升级
阶段升级不由完成课数决定。A0 必须达到固定核心词句门槛并通过两次不同题组的听说读写评估;初次见面对话仅是其中一部分。可提前评估但不得未通过就跳级。A1→A2 的旅行日模拟是后续方向,须先补齐 A1 标准才能启用。完整规则见 [学习引擎规格](LEARNING-ENGINE-SPEC.md)。
## 4. 学习体验与信息架构
### 4.1 主导航
| 页面 | 用户要做什么 | 关键内容 |
|---|---|---|
| 首页 | 知道今天学什么 | 一张“今日任务”卡、到期复习、上次未完成课程 |
| 学习 | 完成一节新课 | 情境、词句、听说读写任务 |
| 对话 | 在安全的情境中开口交流 | 场景卡、语音/文字对话、对话总结 |
| 复习 | 巩固个人薄弱点 | 忘记的词、说错的句、听错的音频 |
| 我的 | 看得见进步 | 当前等级、掌握句型、练习记录、目标设置 |
课程、阅读和对话中的英文均可点词或点短语查看当前语境下的中文意思、音频、短例句,并手动加入复习;短语优先于单词。查词卡不是独立主导航,关闭后回到原步骤。
首页只突出一个主要按钮:`开始今天的 20 分钟学习`。对话场景由“对话”页管理;不设置独立的“场景”主导航,避免选择过载。
### 4.2 新用户流程
```text
选择目标(生活 / 旅行 / 工作起步)
→ 选择每日时间(10 / 20 / 30 分钟)
→ 可选入门测评
→ 得到起点与第一节课
→ 完成一次开口练习
→ 建立明日复习计划
```
### 4.3 每日学习循环
```text
到期复习与补救(按时间选任务)
→ 教学段:预热 → 带帮助练习 → 受控对话 → 独立尝试
→ 完成页:会说什么、明天复习什么
```
10 分钟模式按复习/补救 3 分钟、教学/续学 6 分钟、总结 1 分钟安排;20 分钟为 5+14+1,30 分钟为 8+20+2。教学额度包含预热、听说读写、课程内对话和独立尝试,不能再次加时。剩余时间不足时保存下一步骤,未完成技能留待续学。积压时减少新内容,规则见学习引擎。
### 4.4 每日内容选择
每日任务按预算从“到期复习、未完成课/主线新课、错误修复、短对话”选择,缺少到期项或错误项时不硬凑。AI 不能随机决定能力主线,只能生成通过校验的变式。具体优先级见 [学习引擎规格](LEARNING-ENGINE-SPEC.md)。
每个包含新语言的教学段在听力前先提供 1–3 分钟“今天先认识”的词汇预热:只展示本段会用到的 3–6 个词或词块(不超过该段预算),逐项播放英文、显示当前句义中文与一个短例句,再完成一次轻量匹配/听辨。用户可跳过或回看;预热不是掌握测试,不能因看过词卡就增加掌握度或自动完成课程。
## 5. 单课内容模板
每个教学分段只解决一个明确任务,最多 8 个新词汇项、2 个主动产出句型、2 个新增理解用词块;不要求凑足新词。A0 每段约 10–15 分钟,数字/字母/星期等种子主题可拆段或分多天。对话额外新词另限每轮 1 个、整段 2 个,评估为 0。下表点餐是后续 A1 模板示例,M1 使用自我介绍等 A0 内容。
| 步骤 | 形式 | 示例:在咖啡店点单 |
|---|---|---|
| 课前预热 | 词块卡 + 音频 + 轻量匹配 | `coffee``tea``I'd like ...` |
| 情境导入 | 图片 + 10 秒音频 | 顾客走进咖啡店 |
| 听懂 | 选择/点词 | 听 `What would you like?`,选择正确含义 |
| 词句应用 | 把预热词放进句子,必要时回看 | 用 tea 替换 coffee,不重复整套释义卡 |
| 跟读 | 逐句录音 | 跟读 `I'd like a coffee, please.` |
| 阅读 | 4–6 句短对话 | 店员与顾客的订单对话 |
| 输出 | 角色扮演 | AI 问:`What would you like?`,用户回答 |
| 写作 | 改写/自由输入 | 将 coffee 改成自己的饮品 |
| 独立尝试 | 撤掉句框、翻译和答案,换任务值 | 看新饮品图独立表达;可复用对话最后一轮 |
| 结课 | 能力回顾 | “你现在能礼貌地点一杯饮料。” |
### 5.1 固定的反馈格式
写作和已确认的口语转写反馈均遵循以下顺序,首版一次最多指出一个问题;无错误时只给可选替换,不强行纠错:
1. 先肯定已表达清楚的部分;
2. 给出用户原句;
3. 给出“正确版”;
4. 有必要时给“更自然版”;
5. 用不超过两句中文解释;
6. 给一个替换词的小练习。
示例:
```text
你说清楚了想喝咖啡,很好。
你的句子:I want a coffee.
更礼貌的说法:I'd like a coffee, please.
在点餐时,I'd like... 比 I want... 更自然、礼貌。
现在试试:I'd like a tea, please.
```
## 6. 初始主题库(持续扩展)
下列主题是首轮内容,不是阶段总课数或升级门槛。用户是否进入下一阶段由出口任务、词句掌握和真实对话表现决定;未达标时,系统围绕薄弱点继续提供同阶段的变式练习与新情境。
### A0:起步(110
| 编号 | 主题 | 课后能做什么 |
|---:|---|---|
| 1 | 你好,我是… | 打招呼并介绍姓名 |
| 2 | 字母与拼写姓名 | 拼读自己的名字 |
| 3 | 你好吗? | 问候并简单回应 |
| 4 | 数字与电话号码 | 分段学习 0–10 和虚拟三位号码片段 |
| 5 | 这是什么? | 说出常见物品名称 |
| 6 | 我来自… | 介绍国家/城市 |
| 7 | 我的家人 | 介绍一位家人 |
| 8 | 今天星期几? | 分段学习七天与 1–12 整点,不教日历日期 |
| 9 | 我喜欢… | 表达一项喜好 |
| 10 | A0 小对话 | 完成 45 秒自我介绍 |
### A1:生存英语(1130
| 编号 | 主题 | 课后能做什么 |
|---:|---|---|
| 11 | 在咖啡店点单 | 点饮料并表达礼貌需求 |
| 12 | 在餐厅点餐 | 点餐、询问推荐 |
| 13 | 结账与价格 | 询价、付款、确认金额 |
| 14 | 买衣服 | 询问尺码、颜色、试穿 |
| 15 | 超市购物 | 找商品、说数量 |
| 16 | 问路 | 询问地点与方向 |
| 17 | 打车 | 告知目的地并确认路线 |
| 18 | 公交与地铁 | 买票、问站点、确认下车 |
| 19 | 入住酒店 | 办理入住、说明预订信息 |
| 20 | 酒店里的问题 | 要毛巾、报告房间问题 |
| 21 | 在机场 | 办理值机、问登机口 |
| 22 | 看病不舒服 | 说明简单症状、听懂基本建议 |
| 23 | 预约时间 | 约见面、改时间 |
| 24 | 打电话留言 | 留下姓名、号码和简短信息 |
| 25 | 认识新朋友 | 询问职业、兴趣和居住地 |
| 26 | 介绍日常生活 | 说作息和常做的事 |
| 27 | 今天的天气 | 谈天气与穿衣建议 |
| 28 | 周末计划 | 说将要做的事 |
| 29 | 请求帮助 | 礼貌求助、感谢与回应 |
| 30 | A1 生活挑战 | 完成 3 分钟多场景模拟 |
## 7. 功能需求与验收标准
### 7.1 分级与推荐
- 系统根据测评、已完成课程和复习表现推荐下一课。
- 用户可自主切换到低一级课程;切换不丢失原有进度。
- 首页展示推荐原因,例如“你已掌握自我介绍,下一步练习数字”。
验收:用户完成首课后,再次打开首页时能在一个主按钮内进入下一步,且无需浏览课程目录。
### 7.2 听力与阅读
- 音频支持正常/慢速播放、逐句重复及中英字幕切换。
- A0 教学模式可显示中文辅助;独立听力题默认隐藏字幕和翻译,查看后仅记辅助练习。正式评估按 A0 标准限制提示。
- 阅读题以理解意思和情境为主,避免孤立翻译题。
- 教学中的英文词和词块可点开查词:显示当前句义、中文释义、播放、慢放和已学例句,可手动加入复习。动态释义未审核时须标明,不自动计入核心词汇。
验收:用户在不离开课程或对话的情况下,可打开并关闭一个词/短语的查词卡;点 `from` 能看到“来自;从……”及 `I'm from Hong Kong.`。课程中查词不影响完成;独立练习和评估中先提示切换为练习,不能把查词后的正确答案算独立能力。
验收:每课至少有 1 个听力理解任务、1 段不超过 6 句的可点击文本。
### 7.3 口语
- 用户可跟读、重录,并查看原音频和自己的录音。
- 系统反馈已确认表达中的任务信息,提供示范与回听;不能仅凭 ASR 文字诊断发音或保证真人可理解度,不输出发音分数。
- 角色扮演有明确身份、目标与 3–5 轮上限;用户卡住时可以查看提示。
验收:每课至少一次录音输出和一次情境回答;用户即使识别失败也能看文字示范后继续完成课程。
### 7.4 写作与 AI 纠错
- A0 以排序、替换词和填空为主;从第 6 课起加入 1 句自由写作。
- 第 5 课由排序过渡到看图写完整句;第 6–9 课逐步撤掉句框,第 10 课独立写姓名、地点、喜好并复练物品/时间句。前期填空不能作为完整句写作证据;具体任务见课程包。
- AI 不因标点、大小写等小问题掩盖用户表达成功;先处理影响理解的问题。
- AI 不编造用户未学过的复杂表达,建议要符合当前等级。
验收:在线完成判定的自由写作有简短中文反馈与重写机会;正确表达不强行修改。断网或判定不确定时保存待反馈,不伪造判定,也不修改掌握度。
### 7.5 复习
- 按稳定词句 ID 与薄弱技能形成复习项;同一词句的不同题目合并证据,不重复生成核心项。技术失败不进入错题队列。
- 四个复习检查点以各词句首次完成正式教学为起点,正常在第 1、3、7、14 天出现,不等待整课完成;预热和查词不触发此日程。
- 复习按时间预算选任务,区分任务数与覆盖词句数;一题只为有直接证据的目标记账。最早到期项和近期错项轮流安排,积压时暂停推荐新段,用户可自主继续。
- 单次遗忘先标记需复核并次日换题,保留掌握历史;复核失败才诊断降级,检查点最多退回一个,不统一重走四次。
验收:用户在课程中写错句型后,次日首页的复习中能看到该句型的简短变式题。
### 7.6 AI 对话练习
AI 对话区分以下模式,不能将受控场景误做成无限聊天:
| 类型 | 目的 | 典型时长 | 例子 |
|---|---|---:|---|
| 课程角色扮演(M1) | 巩固本课 1–2 个句型 | 通常 3–5 轮 | 介绍姓名和来自哪里 |
| 独立受控场景(M1) | 在明确任务中复练已学内容 | 通常 3–5 轮 | 第一次见面、介绍物品 |
| 自由主题对话(M2) | 更开放地迁移已学内容 | 后续定义 | 聊日常、旅行计划 |
“对话”页提供场景卡片而非空白聊天框。用户选择场景、身份和难度后开始;系统在开场前用中文说明目标,例如“你是顾客。请点一杯自己喜欢的饮料,并询问价格。”
每次对话应遵循以下流程:
```text
选择场景与难度
→ 选择文字或语音输入
→ AI 用当前等级范围内的英文开场
→ 用户回答;可查看一个提示或重新说
→ AI 自然回应并推进任务
→ 达成目标或到达轮数上限(M1 最多 5 个用户回答轮)
→ 对话总结、纠错与个人复习卡
```
#### 对话能力要求
- 默认以语音优先;用户可随时切换文字输入,避免嘈杂环境阻断学习。
- AI 在对话进行时优先理解和回应,不逐句打断纠错。只有表达无法理解、用户主动请求或当前关键句型错误时,才给一句简短提示。
- 结束后给出三部分总结:`你完成了什么``一个最值得改进的表达``下次可尝试的新句子`
- 回复的词汇、句长和语速受当前等级约束;A0–A1 每轮只包含一个问题或任务。
- 每个场景都有可衡量的完成条件,例如点餐场景须说出饮品、数量/规格(如适用)并完成确认。
- 用户可使用“提示”“慢一点”“翻译”“重说”四个求助按钮;求助不会终止练习,但按规则标记辅助证据。
- 用户可点 AI 消息中的词或短语查询,不离开当前会话;查词后 AI 不重复开场或丢失已回答的信息。
#### 首批对话场景
| 等级 | 场景 | 用户目标 | 完成条件 |
|---|---|---|---|
| A0 | 初次见面 | 介绍姓名与来自哪里 | 至少完成姓名、城市两项信息 |
| A0 | 认识新同学 | 问候并问对方姓名 | 完成一问一答 |
| A1 | 咖啡店 | 点饮料 | 说出饮品并礼貌确认订单 |
| A1 | 餐厅 | 点餐与询问推荐 | 至少提出一项需求和一个问题 |
| A1 | 问路 | 找到车站 | 问地点并复述关键方向 |
| A1 | 酒店入住 | 办理入住 | 说明姓名、预订与入住需求 |
| A1 | 约时间 | 与朋友约见 | 提出时间并达成确认 |
| A1 | 小病不适 | 向药店工作人员说明症状 | 说明一个症状并回答一个追问 |
#### 对话安全与质量边界
- AI 的角色、目标和可使用的语言范围由场景配置控制;不得让模型自由偏离到高难度、攻击性或不适龄的话题。
- AI 不假装真人,不提供医疗、法律、金融等高风险判断;看病场景仅训练语言表达,并提示用户寻求专业帮助。
- 用户语音及转写须征得明确授权,提供删除入口,并避免将录音用于未说明的训练目的。
- 低置信度的语音识别结果要允许用户编辑文字后再发送,不能直接判错。
验收:A0 用户从进入场景到完成一次 3 轮语音/文字对话不超过 5 分钟;结束页必须产生至少 1 条可复习的个人化内容,且用户能一键再次练习同一场景。
## 8. 内容规范
- 所有对话先由英语母语编辑或高质量审核流程校验自然度。
- 每个新词必须至少在听力、阅读和口语中的两个环节出现。
- 同一课只引入一个主要语法点,解释限制为 80 字中文以内,并用对话例句说明。
- 发音提示用可听示范与简短动作说明,音标可选展开;不使用汉字谐音作为标准,不仅凭转写诊断音素。
- 语速、口音和文化背景保持多样,但 A0 优先清晰、慢速、标准化语音。
## 9. 数据与进度
为每位用户记录:当前等级、目标、课程完成状态、词汇/句型掌握度、听力正确率、常见写作错误、口语尝试次数和复习到期时间。进度页避免只显示“学了多少词”,应显示可完成的任务,例如“你已能用英语点餐”。
## 10. 个人使用验收清单
不追踪留存、活跃或增长数据。每周只用以下问题检查工具是否真的有帮助:
- 我能否在不看中文提示时完成本周的 AI 情境对话?
- 我是否能重说上周的关键句,并替换其中一个词?
- 系统给出的纠错是否少而清楚,且我知道下一次该怎么说?
- 学习记录是否准确保存了已完成的课程和需要复习的句子?
## 11. 版本路线图
### M1A0 个人学习闭环
完成 10 节 A0 种子课及必要分段、听说读写、基础 AI 写作反馈、受控对话、每日变式/补救、复习、掌握证据、进度及四技能出口评估;包含服务配置、离线降级和中断恢复。课程总量不限,未掌握持续同阶段补练。通过 A0 后若 A1 内容未就绪,显示待提供。完整范围与验收见《开发与验收约定》。
### M2:完整个人学习路径
在 M1 闭环基础上新增 A1 能力地图、核心清单、种子主题及出口评估,扩展生活场景与自由主题对话;可增加加密导出/导入。M1 已有的复习、纠错、场景页和进度不重复列为 M2 首次交付。
### M3:个性化与 A2
加入更细的能力诊断、学习计划调整、旅行/工作目标路径,以及 A2 课程。在你完成 A1 路径并确认现有内容和反馈确实满足个人学习需要后再启动。
## 12. 版权与内容来源
BBC Learning English、学习资源合集和新概念英语可用于研究信息架构、学习方法或作为外链推荐。不得未经授权抓取、复制、改编或在应用内重新分发它们的文本、音频、视频和练习。
首版课程、对话、插画与音频使用原创或许可覆盖当前用途的素材;纳入应用时即记录来源、许可范围和署名要求,不以未来上线为前提。
+78
View File
@@ -0,0 +1,78 @@
# M1 全部原型设计说明
## 范围
本设计覆盖个人学习工具 M1 的全部核心页面与关键状态,适配 Flutter 的 iPhone、Android 手机与 macOS。手机界面为第一优先级;Mac 复用相同的信息结构,以双栏和更宽的内容区呈现。
范围以 [M1 开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准,新增状态的文字线框见 [M1 页面规格](M1-WIREFRAMES.md) P04a、P09P14 及 P09a。课前词汇预热和查词卡已同步到 [交互原型](learning-app-prototype.html);其他新增评估、补练与服务状态仍以文字线框为准,尚未同步为 HTML 页面。
## 页面清单
| 编号 | 页面 / 状态 | 主要目的 | 关键动作 |
|---:|---|---|---|
| 01 | 目标设置 | 设置学习目的和每日时长 | 进入起点定位 |
| 02 | 起点定位 | 选择或轻量确认起始等级 | 开始学习 / 跳过 |
| 03 | 首页 | 呈现唯一的今日主任务 | 开始课程或短对话 |
| 04 | 学习地图 | 查看当前阶段与课程顺序 | 进入课程 |
| 05 | 听力练习 | 在清晰音频中理解核心句 | 选择答案、重放、慢放 |
| 06 | 跟读练习 | 模仿示范音并录音 | 录音、重说、转文字 |
| 07 | 造句练习 | 将词句转成自己的表达 | 输入、提交、进入对话 |
| 08 | 对话场景 | 选择当前等级的任务型对话 | 开始场景 |
| 09 | AI 对话 | 在限制范围内完成 3–5 轮交流 | 说话、文字输入、提示、慢放 |
| 10 | 转写确认 | 在语音识别不确定时保护用户 | 确认、修改、重说 |
| 11 | 练习总结 | 给出一个成功点与一个重说句 | 重说、加入复习、回首页 |
| 12 | 今日复习 | 用微对话复练到期表达 | 说话、提示、进入下一项 |
| 13 | 我的进步 | 展示可完成的真实能力 | 查看下一能力、调整设置 |
| 14 | 设置 | 控制学习节奏、声音、数据与 AI 服务 | 更改偏好、配置与测试提供商、删除所选数据 |
| 15 | Mac 适配 | 展示桌面端同一学习结构 | 继续课程或进入对话 |
| 16 | 阅读子页 | 理解短文本,不用听力替代 | 阅读、答题、教学态查释义 |
| 17 | 阶段评估准备 | 说明核心门槛、题组与提示规则 | 检查设备、分技能开始/继续 |
| 18 | 阶段评估结果 | 区分通过、需练习、待评估 | 看证据、补练、A1 待提供 |
| 19 | 补练与补测 | 针对薄弱能力取得新证据 | 完成补练、换题复测 |
| 20 | AI 配置与错误状态 | 验证实际能力、提供本地降级 | 测试、修正配置、重试 |
| 21 | 通用发音练习 | 示范和录音对比,不诊断音素 | 慢放、回听、重录、返回 |
| 22 | 查词卡 | 在原步骤理解词或词块 | 看当前句义、播放、慢放、加入复习、关闭返回 |
| 23 | 课前词汇预热 | 在正式任务前认识当天的新语言 | 逐项听、看句义、轻量匹配、跳过或回看 |
| 24 | 独立尝试 | 逐步撤掉帮助,取得真实作答证据 | 换题独立回答、恢复帮助、稍后续学 |
## 关键流转
```text
首次:目标设置 → 起点定位 → 首页
学习:首页 → 到期复习(有则按预算) → 预热 → 带帮助的听说读写 → AI 对话 → 独立尝试 → 总结
识别不确定:AI 对话 → 转写确认 → AI 对话 / 总结
查词:课程 / 阅读 / AI 对话 → 查词卡 → 返回原步骤
复习:首页 → 今日复习 → 首页
进度:首页 → 我的进步 → 设置
评估:我的进步 → 评估准备 → 听说读写 → 结果 → 补练 → 换题补测
通过:全部门槛满足 → A0 已通过 → A1 待提供 / 继续 A0 巩固
服务:设置 → AI 配置 → 连接测试 → 保存 / 修正
```
## 视觉与交互规则
- 使用低饱和绿色作为行动与完成状态,暖橙只用于“再练一次”等温和提醒。
- 每个手机页面仅保留一个高强调操作;其余操作保持低强调。
- 英文是学习内容,系统说明和反馈使用中文。
- 大型底部录音区域始终可被单手触达;文字输入是继续练习的备选,但不是口语达标的等价证据。
- 识别低置信度时,使用“我没有完全听清”,不能显示“你说错了”。
- 总结页只显示一个待改进句,避免一次纠正多个语法或发音问题。
- 手机上的底部导航固定为:首页、学习、对话、复习、我的。
- Mac 不增加独立功能,只将相同内容改为左侧导航 + 宽内容区域。
- AI 服务可选 Gemini API、OpenAI API 或 OpenAI 兼容反代;反代仅用于文字 AI,配置保存在设备安全存储,不展示网页登录凭据。
- 教学与独立评估明确区分;独立听力先隐藏字幕/翻译,求助后标记辅助,不混入独立成功。
- 教学中点英文词或短语可打开查词卡,短语优先于单词;显示当前语境中文意思、示范音、慢速与已学例句。关闭后不丢失步骤或对话轮次,可手动加入复习。
- 独立练习和阶段评估隐藏查词入口;需要查词时明确切换为教学练习,并用不同题补测,不能把查词后的正确答案计入独立能力。
- 每个有新语言的教学段在正式任务前显示 1–3 分钟词汇预热,通常 3–6 项;预热是熟悉输入而非掌握测试,允许跳过、回看,不增加掌握度或复习到期项。
- 预热、对话、独立尝试均计入教学段时长;日预算不足保存步骤,不重复词卡教学或自动结课。独立尝试可复用对话最后一轮,恢复提示后标记辅助完成。
- 复习按时间选任务,显示任务数和实际覆盖词句数;积压时减少新段,单次遗忘先安排复核,起算日期为词句首次完成正式教学。
- 通用状态必须覆盖:空复习、断网、服务未配置、权限拒绝、缺音频、生成异常、待反馈、中断恢复、内容质疑与删除确认;行为见页面规格 P13。
- 四技能和核心门槛分开展示;完成十课、轻量定位或手动修改转写都不能直接解锁下一阶段。
## M1 未包含的界面
- 登录、注册、找回密码、付费、订阅和通知;
- 用户匹配、群聊、排行榜、公开个人主页;
- 自动跨设备同步;
- 加密导出/导入(留到 M2);
- 实时全双工通话、精细发音分数或公开分享录音。
+159
View File
@@ -0,0 +1,159 @@
# 技术架构:Flutter 手机优先、支持 Mac 的个人学习工具
> 当前目标:iPhone、Android 手机与 Mac;个人自用;无需发布、登录或自建服务器。
> 原则:手机优先、本地优先;每台设备都可以独立完成全部学习功能。
本文技术方向不代表已验证实现。M1 的范围、内容结构、证据实体及异常行为以 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准;课程总量不限,阶段核心清单固定版本。
## 1. 推荐技术路线
使用 **Flutter / Dart** 构建 iPhone、Android 与 macOS 客户端,共享课程、学习引擎和对话状态代码;布局以手机为第一优先级,Mac 只是更宽的阅读和练习界面。
| 层级 | 方案 | 用途 |
|---|---|---|
| 客户端 | Flutter / Dart | iPhone、Android 优先界面,macOS 适配 |
| 状态管理 | Riverpod 或同类单向状态方案 | 课程步骤、录音状态、对话状态、复习队列 |
| 本地数据 | SQLite(Drift) | 证据、掌握、复习、会话、评估、进度和临时词条分表保存;旧版完整快照只作迁移/恢复回退,正常写入不再生成快照 |
| 课程内容 | 内置 JSON | A0 课程、场景、词句、词条释义、练习规则 |
| 录音与播放 | Flutter 跨平台音频插件 + 原生适配 | 录音、回放、慢速播放、音频缓存 |
| AI 对话 | 云端模型 API 或本地模型 | 受控情境对话、纠错、每日变式 |
| 语音转写 | 云端转写或各平台原生语音能力 | 将用户英语录音变成可确认文字 |
| 英文朗读 | 跨平台 TTS 或云端语音合成 | 播放 AI 的英语回复和标准示范音 |
| 密钥保存 | 平台安全存储 | iOS Keychain、Android Keystore、macOS Keychain |
## 2. 手机优先原则
- 单手操作:录音、重说、提示等核心按钮位于手机屏幕下方。
- 一屏一个任务:课程和对话不要求用户滚动查找下一步。
- 断网可继续内置课程、本地判题、学习记录、已有音频和模板复习;AI 动态生成、在线对话/反馈及云端语音需网络。原生 STT/TTS 离线可用性须逐设备探测;缺音频的听力题与开放答案反馈留待补做。
- 声音可选:公共场合可切换为文字输入与静音阅读,不应阻止完成练习。
- Mac 复用:Mac 端展示同一课程和本机进度,可更适合阅读、输入和查看历史,不重新设计一套学习流程。
## 3. 是否需要后台
不需要自行部署远程后台。
```text
iPhone / Android / Mac App
├─ 本地课程 JSON
├─ 本地学习数据库
├─ 本地录音与音频缓存
└─ 通过可切换的 AI 提供商调用对话 / 转写 / 语音服务
```
### 3.1 跨设备同步
M1 不做自动跨设备同步。每台设备保存自己的学习记录,但都内置相同课程并可独立使用。
如需在手机开始后在 Mac 继续,可在后续版本增加“加密导出/导入学习进度”。导出的内容包括:
- 当前课程和步骤;
- 词句掌握状态与复习到期时间;
- 对话文字摘要、错误项与重说句;
- 偏好设置;
- 不包含原始录音。
原始录音默认只保留在产生它的设备,以节省空间并减少隐私暴露。若以后必须自动同步,可选择用户自有的云盘同步或小型后台代理;这不属于 M1。
### 3.2 AI 提供商配置与网络请求
个人自用时,模型服务密钥或兼容代理 Token 可分别保存在 iPhone、Android 和 Mac 的安全存储中,由客户端直接调用选定的 AI 服务。不要把密钥写进课程 JSON、源代码或可分享的配置文件。
这只适用于你自己的设备和开发构建。若未来发布给他人使用,必须改为通过后台代理请求,以保护密钥、控制成本和处理滥用。
客户端只依赖统一的 `AIProvider` 接口,而不将课程逻辑写死到单一服务商:
```text
AIProvider
├─ GeminiProvider
├─ OpenAIProvider
└─ OpenAICompatibleProvider
```
每个提供商配置包含:提供商类型、模型名、接口地址、Token 和能力开关(文字对话、转写、语音)。设置页只显示当前选中的提供商;切换提供商不影响本地课程、掌握状态和复习队列。
### 3.3 OpenAI 兼容反代(CLIProxyAPI
CLIProxyAPI 可作为 `OpenAICompatibleProvider` 的可选接口地址,用于文字情境对话、课程变式、纠错与结构化返回。它不是 M1 的唯一依赖;不可用时可切换到 Gemini API 或 OpenAI API。
使用前必须满足:
- 手机和 Mac 都能访问该地址。手机中的 `localhost` 指向手机本身,不能访问 Mac 上运行的反代;需使用可访问的局域网地址或有效 HTTPS 地址。
- 反代须通过 App 的地址/鉴权、选定模型短文本及结构化返回测试;流式可选,可用非流式降级。测试成功仅证明当前文字能力,不证明语音或全部兼容能力。
- 反代 Token 仅保存在平台安全存储;不得使用网页 Cookie、账号密码或 OAuth 会话作为 App 凭据。
- 如果反代基于消费者订阅的登录态或令牌,使用前须自行确认授权方式与相关服务条款。
反代先只承担文字 AI。转写优先设备原生能力,朗读优先设备 TTS;设备权限、英语语言包与实际可用性验证通过后才能运行对应语音流程。文字降级允许继续学习,但不能代替口语/听力达标。
## 4. M1 数据模型
下图仅为概览。实现必须补充 StageDefinition、AbilityNode、Task/Asset、AttemptEvidence、StudySession、StageAssessment 与 ProviderConfiguration,字段和事务约束见《开发与验收约定》第 3 节。掌握度应能从有版本的分技能证据重算,AI 不直接写入升级状态。
```text
Course
└─ Lesson
├─ vocabulary / patterns
├─ listening / reading / writing tasks
└─ dialogue scenario and completion rules
LexiconEntry
├─ word or phrase, contextual Chinese meaning and example
├─ audio reference and approval/source status
└─ related ability / vocabulary / pattern IDs
LearnerProfile
├─ current level and current lesson
├─ daily study duration
└─ preferences (voice, subtitles, speed)
MasteryItem
├─ word or sentence pattern
├─ state: new / recognize / recall / use / master
├─ success and failure history
└─ next review date
SavedWord
├─ lexicon entry and saved time
└─ optional low-priority review state
ConversationSession
├─ scenario, turns and task progress
├─ transcript summary
├─ one improvement sentence
└─ review item
```
## 5. M1 的 AI 提供商与模型组合
ChatGPT 与 Gemini 的消费者订阅可用于手动编写课程、检查内容和测试提示词,但不能直接作为 Flutter App 的自动调用额度。App 需要配置各自的开发者 API Key;订阅与 API 计费/额度分开处理。
文字 AIProvider、SpeechRecognizer、SpeechSynthesizer 分别抽象;选择文字代理不意味着其也支持语音。M1 手动选择一个文字提供商,不同时调用多家或自动切换付费服务。下表为候选配置,模型名、额度及价格未在本次文档修订中联网验证,使用时以账号实际能力和连接测试为准,不把免费额度作为运行保证。
| 能力 | M1 默认方案 | 可切换方案 | 说明 |
|---|---|---|---|
| 情境对话、课程变式、纠错、结构化 JSON | Gemini API 免费层(先用于个人原型) | OpenAI API 的 GPT-5 mini | 两者均须各自 API Key;先用已有 Google 账号可访问的免费额度验证流程,额度或质量不够时切换 OpenAI |
| 兼容反代文字对话 | 不默认启用 | CLIProxyAPI 的 OpenAI 兼容地址 | 在设置中手动填写 Base URL、Token 和模型名;只在接口能力与授权边界明确时启用 |
| 英语语音转写 | 设备原生语音识别 | OpenAI API 的 GPT-4o mini Transcribe | 先避免增加 API 成本;低可信度时必须允许确认、修改或重说 |
| 英文朗读 | 设备 TTS | 云端语音合成 | M1 优先本机朗读,减少成本并支持基础离线使用 |
| 实时全双工语音 | 暂不使用 | 后续单独评估 | 当前采用 3–5 轮的轮流对话,实时打断不是 M1 需求 |
每次 AI 对话都应传入当前等级、场景、允许词句、用户已回答内容和任务完成条件;不要用模型决定课程顺序。模型用量主要来自对话和反馈,因此应限制为 3–5 轮短对话,并缓存已生成的课程音频和练习变式。
官方参考:[OpenAI API Key 快速开始](https://developers.openai.com/api/docs/quickstart)、[GPT-5 mini](https://developers.openai.com/api/docs/models/gpt-5-mini)、[OpenAI API 定价](https://developers.openai.com/api/docs/pricing)、[Gemini API Key](https://ai.google.dev/gemini-api/docs/api-key)、[Gemini API 计费](https://ai.google.dev/gemini-api/docs/billing)。
## 6. M1 实现顺序
1. 完成一课听说读写、本地证据与恢复流程。
2. 加入文字 AI 对话、结构校验、反馈与本地降级。
3. 加入 TTS、录音、语音转写与确认;分别记录听说读写证据。
4. 完成每日推荐、复习、变式生成、A0 出口评估与全部种子内容审校。
5. 完成 iPhone、Android 真机及 macOS 同流程验证,覆盖《开发与验收约定》的异常测试。加密导出/导入留到 M2。
## 7. 当前不做
- 自建后端、账号、公开用户数据库;
- Web、多人共享、社交匹配与自动跨设备同步;
- 支付、订阅、通知、运营分析;
- 云端长期保存原始录音;
- 实时全双工电话式 AI 通话。
- 加密导出/导入、个性化音素诊断;前者留到 M2,后者须另行验证。
+292
View File
@@ -0,0 +1,292 @@
<div id="learning-app-prototype" aria-label="开口英语 M1 全部原型页面">
<style>
#learning-app-prototype {
--ka-paper: light-dark(#f5f7f3, #151a16);
--ka-surface: light-dark(#ffffff, #202722);
--ka-surface-2: light-dark(#f1f5f0, #2b342c);
--ka-ink: light-dark(#19211b, #eef4ee);
--ka-muted: light-dark(#647268, #afbbb1);
--ka-line: light-dark(#d8e1d9, #3b473d);
--ka-green: light-dark(#176b46, #6ed29a);
--ka-green-ink: light-dark(#ffffff, #102116);
--ka-soft-green: light-dark(#e2f3e8, #173625);
--ka-warm: light-dark(#fff0e3, #462b1d);
--ka-warm-ink: light-dark(#9e4c18, #ffb582);
color: var(--ka-ink);
font-family: ui-rounded, "SF Pro Rounded", "PingFang SC", system-ui, sans-serif;
}
#learning-app-prototype * { box-sizing: border-box; }
#learning-app-prototype button { font: inherit; }
#learning-app-prototype .studio { display:grid; grid-template-columns: 180px minmax(300px, 430px) minmax(210px, 1fr); gap:20px; align-items:start; }
#learning-app-prototype .panel { background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:18px; padding:14px; }
#learning-app-prototype .brand { font-size:18px; font-weight:500; letter-spacing:-.02em; margin:1px 0 2px; }
#learning-app-prototype .small { color:var(--ka-muted); font-size:12px; line-height:1.45; margin:0; }
#learning-app-prototype .screen-list { display:flex; flex-direction:column; gap:3px; margin-top:15px; }
#learning-app-prototype .screen-link { text-align:left; border:0; border-radius:10px; color:var(--ka-ink); background:transparent; padding:9px 10px; font-size:13px; }
#learning-app-prototype .screen-link[aria-pressed="true"] { background:var(--ka-soft-green); color:var(--ka-green); font-weight:500; }
#learning-app-prototype .legend-title { font-size:13px; font-weight:500; margin:0 0 10px; }
#learning-app-prototype .flow { display:flex; flex-direction:column; gap:8px; }
#learning-app-prototype .flow-row { border-left:2px solid var(--ka-line); padding:3px 0 3px 9px; font-size:12px; line-height:1.35; }
#learning-app-prototype .flow-row strong { display:block; font-weight:500; }
#learning-app-prototype .flow-row.active { border-color:var(--ka-green); }
#learning-app-prototype .chip { display:inline-block; margin:4px 4px 0 0; padding:3px 7px; border-radius:999px; font-size:11px; background:var(--ka-surface-2); color:var(--ka-muted); }
#learning-app-prototype .phone-wrap { display:flex; justify-content:center; }
#learning-app-prototype .phone { width:352px; min-height:690px; position:relative; overflow:hidden; border:8px solid light-dark(#1c251e, #090c0a); border-radius:35px; background:var(--ka-surface); box-shadow:0 18px 40px light-dark(rgba(16,35,22,.17), rgba(0,0,0,.35)); }
#learning-app-prototype .phone.mac { width:430px; border-width:7px; border-radius:17px; }
#learning-app-prototype .status { height:28px; display:flex; align-items:center; justify-content:space-between; padding:0 17px; font-size:11px; color:var(--ka-muted); background:var(--ka-surface); }
#learning-app-prototype .appbar { min-height:53px; display:flex; align-items:center; gap:8px; padding:7px 16px 11px; background:var(--ka-surface); }
#learning-app-prototype .back { width:32px; height:32px; border:0; border-radius:10px; color:var(--ka-ink); background:var(--ka-surface-2); font-size:18px; }
#learning-app-prototype .appbar h2 { font-size:16px; font-weight:500; margin:0; }
#learning-app-prototype .appbody { min-height:548px; padding:12px 16px 82px; background:var(--ka-paper); }
#learning-app-prototype .screen[hidden] { display:none; }
#learning-app-prototype .eyebrow { color:var(--ka-green); font-size:12px; font-weight:500; margin:2px 0 8px; }
#learning-app-prototype h1 { font-size:25px; line-height:1.26; letter-spacing:-.03em; font-weight:500; margin:0 0 8px; }
#learning-app-prototype h3 { font-size:16px; line-height:1.35; font-weight:500; margin:0; }
#learning-app-prototype .bodycopy { color:var(--ka-muted); font-size:13px; line-height:1.55; margin:0; }
#learning-app-prototype .slot { background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:15px; padding:12px; margin-top:11px; }
#learning-app-prototype .slot.selected { background:var(--ka-soft-green); border-color:transparent; }
#learning-app-prototype .slot.warm { background:var(--ka-warm); border-color:transparent; }
#learning-app-prototype .slot-title { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:14px; font-weight:500; }
#learning-app-prototype .slot-note { color:var(--ka-muted); font-size:12px; line-height:1.4; margin-top:4px; }
#learning-app-prototype .choice { width:100%; border:1px solid var(--ka-line); border-radius:13px; padding:12px; background:var(--ka-surface); color:var(--ka-ink); text-align:left; margin-top:8px; }
#learning-app-prototype .choice.active { border-color:var(--ka-green); background:var(--ka-soft-green); }
#learning-app-prototype .choice strong { display:block; font-size:14px; font-weight:500; }
#learning-app-prototype .choice span { color:var(--ka-muted); font-size:12px; }
#learning-app-prototype .primary { width:100%; border:0; border-radius:13px; padding:13px 12px; margin-top:13px; background:var(--ka-green); color:var(--ka-green-ink); font-size:14px; font-weight:500; }
#learning-app-prototype .secondary { width:100%; border:1px solid var(--ka-line); border-radius:13px; padding:12px; margin-top:8px; background:var(--ka-surface); color:var(--ka-ink); font-size:14px; }
#learning-app-prototype .text-action { border:0; background:transparent; color:var(--ka-green); font-size:13px; padding:10px 0; }
#learning-app-prototype .meter { height:7px; border-radius:999px; overflow:hidden; background:var(--ka-line); margin:12px 0 14px; }
#learning-app-prototype .meter > span { display:block; height:100%; background:var(--ka-green); border-radius:inherit; }
#learning-app-prototype .seg { display:flex; gap:4px; margin:5px 0 14px; }
#learning-app-prototype .seg span { height:5px; flex:1; border-radius:999px; background:var(--ka-line); }
#learning-app-prototype .seg span.on { background:var(--ka-green); }
#learning-app-prototype .word { font-size:27px; font-weight:500; margin:13px 0 2px; }
#learning-app-prototype .ipa { color:var(--ka-muted); font-size:14px; }
#learning-app-prototype .audio { display:flex; align-items:center; gap:10px; margin-top:13px; padding:10px; border-radius:12px; background:var(--ka-surface-2); }
#learning-app-prototype .play { width:34px; height:34px; border:0; border-radius:50%; color:var(--ka-green-ink); background:var(--ka-green); font-size:13px; }
#learning-app-prototype .audio-line { flex:1; height:4px; border-radius:99px; background:var(--ka-line); overflow:hidden; }
#learning-app-prototype .audio-line i { display:block; width:38%; height:100%; background:var(--ka-green); }
#learning-app-prototype .audio small { color:var(--ka-muted); font-size:11px; }
#learning-app-prototype .sentence { margin:15px 0; padding:15px; background:var(--ka-surface); border-radius:15px; font-size:18px; line-height:1.5; }
#learning-app-prototype .mark { color:var(--ka-green); font-weight:500; }
#learning-app-prototype .lookup-word { appearance:none; display:inline; border:0; border-bottom:1px dashed var(--ka-green); padding:0; background:transparent; color:var(--ka-green); font:inherit; font-weight:500; cursor:pointer; }
#learning-app-prototype .lookup-hint { display:flex; align-items:center; gap:6px; margin-top:9px; color:var(--ka-muted); font-size:12px; }
#learning-app-prototype .lookup-hint i { width:17px; height:17px; display:grid; place-items:center; border-radius:50%; background:var(--ka-soft-green); color:var(--ka-green); font-style:normal; font-size:11px; }
#learning-app-prototype .lookup-stage { min-height:548px; padding:12px 16px 24px; background:linear-gradient(180deg, var(--ka-paper) 0%, var(--ka-paper) 42%, light-dark(#e8ede8, #1b211c) 42%, light-dark(#e8ede8, #1b211c) 100%); }
#learning-app-prototype .lookup-context { padding:14px; border-radius:15px; background:var(--ka-surface); opacity:.64; font-size:14px; line-height:1.55; }
#learning-app-prototype .lookup-sheet { margin-top:39px; padding:18px 15px 15px; border-radius:20px 20px 15px 15px; background:var(--ka-surface); box-shadow:0 -8px 26px light-dark(rgba(16,35,22,.12), rgba(0,0,0,.25)); }
#learning-app-prototype .lookup-handle { width:36px; height:4px; margin:0 auto 16px; border-radius:9px; background:var(--ka-line); }
#learning-app-prototype .lookup-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
#learning-app-prototype .lookup-head .word { margin:0 0 2px; }
#learning-app-prototype .lookup-close { width:32px; height:32px; border:0; border-radius:50%; background:var(--ka-surface-2); color:var(--ka-ink); font-size:18px; }
#learning-app-prototype .meaning { margin:12px 0; font-size:16px; line-height:1.5; }
#learning-app-prototype .lookup-example { padding:11px; border-radius:12px; background:var(--ka-soft-green); font-size:13px; line-height:1.5; }
#learning-app-prototype .lookup-example span { display:block; color:var(--ka-muted); margin-top:2px; font-size:12px; }
#learning-app-prototype .chat { display:flex; flex-direction:column; gap:9px; margin:11px 0 14px; }
#learning-app-prototype .bubble { max-width:88%; padding:10px 11px; border-radius:14px; font-size:13px; line-height:1.45; }
#learning-app-prototype .bubble.ai { align-self:flex-start; background:var(--ka-soft-green); }
#learning-app-prototype .bubble.me { align-self:flex-end; background:var(--ka-warm); }
#learning-app-prototype .assist { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0; }
#learning-app-prototype .assist button { border:1px solid var(--ka-line); background:var(--ka-surface); color:var(--ka-ink); border-radius:999px; padding:7px 9px; font-size:11px; }
#learning-app-prototype .record { margin-top:13px; padding:13px; border:1px dashed var(--ka-green); border-radius:15px; text-align:center; background:var(--ka-surface); color:var(--ka-green); font-size:13px; font-weight:500; }
#learning-app-prototype .bottom { position:absolute; z-index:3; bottom:0; left:0; right:0; height:68px; display:flex; justify-content:space-around; padding:7px 9px 9px; border-top:1px solid var(--ka-line); background:var(--ka-surface); }
#learning-app-prototype .navitem { display:flex; flex-direction:column; align-items:center; gap:3px; border:0; background:transparent; color:var(--ka-muted); min-width:49px; font-size:10px; }
#learning-app-prototype .navitem.active { color:var(--ka-green); font-weight:500; }
#learning-app-prototype .nav-dot { width:17px; height:17px; border-radius:6px; background:currentColor; opacity:.85; }
#learning-app-prototype .checkline { display:flex; gap:9px; align-items:flex-start; padding:11px 0; border-bottom:1px solid var(--ka-line); font-size:13px; line-height:1.4; }
#learning-app-prototype .checkline b { color:var(--ka-green); font-weight:500; }
#learning-app-prototype .review-prompt { padding:19px 13px; border-radius:15px; background:var(--ka-soft-green); margin-top:14px; font-size:17px; line-height:1.45; }
#learning-app-prototype .levelmap { margin-top:12px; }
#learning-app-prototype .level { display:grid; grid-template-columns:34px 1fr auto; align-items:center; gap:9px; padding:10px 0; border-bottom:1px solid var(--ka-line); }
#learning-app-prototype .level b { font-size:13px; font-weight:500; }
#learning-app-prototype .level span { font-size:12px; color:var(--ka-muted); }
#learning-app-prototype .level i { width:8px; height:8px; border-radius:50%; background:var(--ka-line); }
#learning-app-prototype .level i.on { background:var(--ka-green); }
#learning-app-prototype .setting { display:flex; align-items:center; justify-content:space-between; padding:13px 0; border-bottom:1px solid var(--ka-line); font-size:14px; }
#learning-app-prototype .setting span { color:var(--ka-muted); font-size:12px; }
#learning-app-prototype .toggle { width:35px; height:20px; border-radius:999px; background:var(--ka-green); padding:3px; }
#learning-app-prototype .toggle i { display:block; width:14px; height:14px; margin-left:auto; border-radius:50%; background:var(--ka-green-ink); }
#learning-app-prototype .mac-body { min-height:590px; display:grid; grid-template-columns:113px 1fr; background:var(--ka-paper); }
#learning-app-prototype .mac-side { padding:15px 10px; background:var(--ka-surface); border-right:1px solid var(--ka-line); }
#learning-app-prototype .mac-side b { display:block; font-size:12px; margin:8px 6px 16px; }
#learning-app-prototype .mac-side span { display:block; padding:8px 6px; border-radius:8px; color:var(--ka-muted); font-size:11px; }
#learning-app-prototype .mac-side span.on { background:var(--ka-soft-green); color:var(--ka-green); }
#learning-app-prototype .mac-main { padding:23px; }
#learning-app-prototype .mac-main h1 { font-size:23px; }
#learning-app-prototype .mac-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:16px; }
#learning-app-prototype .mac-grid .slot { margin:0; min-height:104px; }
@media (max-width: 880px) { #learning-app-prototype .studio { grid-template-columns:180px minmax(300px, 1fr); } #learning-app-prototype .flow-panel { display:none; } }
@media (max-width: 590px) { #learning-app-prototype .studio { display:block; } #learning-app-prototype .panel { margin-bottom:15px; } #learning-app-prototype .screen-list { display:grid; grid-template-columns:1fr 1fr; } #learning-app-prototype .phone { width:100%; max-width:352px; } #learning-app-prototype .phone.mac { width:100%; max-width:430px; } }
</style>
<div class="studio">
<aside class="panel" aria-label="页面目录">
<div class="brand">开口英语</div>
<p class="small">M1 · 手机优先原型</p>
<div class="screen-list" role="group" aria-label="选择页面">
<button class="screen-link" data-screen-target="welcome" aria-pressed="false" type="button">01 目标设置</button>
<button class="screen-link" data-screen-target="placement" aria-pressed="false" type="button">02 起点定位</button>
<button class="screen-link" data-screen-target="home" aria-pressed="true" type="button">03 首页</button>
<button class="screen-link" data-screen-target="map" aria-pressed="false" type="button">04 学习地图</button>
<button class="screen-link" data-screen-target="listen" aria-pressed="false" type="button">05 听力练习</button>
<button class="screen-link" data-screen-target="speak" aria-pressed="false" type="button">06 跟读练习</button>
<button class="screen-link" data-screen-target="write" aria-pressed="false" type="button">07 造句练习</button>
<button class="screen-link" data-screen-target="scenes" aria-pressed="false" type="button">08 对话场景</button>
<button class="screen-link" data-screen-target="dialogue" aria-pressed="false" type="button">09 AI 对话</button>
<button class="screen-link" data-screen-target="confirm" aria-pressed="false" type="button">10 转写确认</button>
<button class="screen-link" data-screen-target="summary" aria-pressed="false" type="button">11 练习总结</button>
<button class="screen-link" data-screen-target="review" aria-pressed="false" type="button">12 今日复习</button>
<button class="screen-link" data-screen-target="progress" aria-pressed="false" type="button">13 我的进步</button>
<button class="screen-link" data-screen-target="settings" aria-pressed="false" type="button">14 设置</button>
<button class="screen-link" data-screen-target="mac" aria-pressed="false" type="button">15 Mac 适配</button>
<button class="screen-link" data-screen-target="dictionary" aria-pressed="false" type="button">16 查词卡</button>
<button class="screen-link" data-screen-target="preview" aria-pressed="false" type="button">17 课前词汇预热</button>
</div>
</aside>
<main class="phone-wrap" aria-live="polite">
<div class="phone" id="prototype-phone">
<section class="screen" data-screen="welcome" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div>
<div class="appbody">
<div class="eyebrow">欢迎</div>
<h1>每天 20 分钟,<br>说出能用的英语。</h1>
<p class="bodycopy">从真实生活场景开始,先做到听得懂、说得清。</p>
<div class="slot selected"><div class="slot-title">你的主要目标</div><div class="assist"><span class="chip">日常生活</span><span class="chip">旅行</span><span class="chip">工作起步</span></div></div>
<div class="slot"><div class="slot-title">每天学习多久?</div><div class="assist"><span class="chip">10 分钟</span><span class="chip" style="background:var(--ka-soft-green);color:var(--ka-green)">20 分钟</span><span class="chip">30 分钟</span></div></div>
<button class="primary" data-go="placement" type="button">继续</button>
</div>
</section>
<section class="screen" data-screen="placement" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div>
<div class="appbody">
<div class="eyebrow">第一步 · 约 3 分钟</div>
<h1>从哪里开始?</h1><p class="bodycopy">选择最接近的状态,之后随时能调整。</p>
<button class="choice active" type="button"><strong>完全零基础</strong><span>从你好、自我介绍开始</span></button>
<button class="choice" type="button"><strong>能说一点</strong><span>认识常见单词或短句</span></button>
<button class="choice" type="button"><strong>能简单对话</strong><span>想说得更自然、更有信心</span></button>
<button class="primary" data-go="home" type="button">开始 3 分钟定位</button><button class="text-action" data-go="home" type="button">直接从第一课开始</button>
</div>
</section>
<section class="screen" data-screen="home">
<div class="status"><span>9:41</span><span>● ● ●</span></div>
<div class="appbody">
<div class="eyebrow">早上好,Shen</div><h1>今天,说 3 句英语。</h1><p class="bodycopy">第 1 课已完成。今天把姓名介绍说得更自然。</p>
<div class="meter" aria-label="当前掌握程度"><span style="width:30%"></span></div><p class="small">A0 起步 · 已完成 3 个核心任务</p>
<div class="slot selected"><div class="slot-title"><span>第 2 课 · 拼读姓名</span><span>12 分钟</span></div><div class="slot-note">先认识 4 个词 · 听 · 说 · 读 · 写</div><button class="primary" data-go="preview" type="button">开始今天的学习</button></div>
<div class="slot"><div class="slot-title">快速练习 · 初次见面</div><div class="slot-note">3 分钟 AI 对话 · 介绍姓名与来自哪里</div><button class="secondary" data-go="dialogue" type="button">开始对话</button></div>
</div>
<nav class="bottom" aria-label="主导航"><button class="navitem active" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
</section>
<section class="screen" data-screen="map" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="home" type="button"></button><h2>A0 起步</h2></div>
<div class="appbody"><div class="eyebrow">学习地图 · 已完成 3 个任务</div><h1>从认识到开口</h1><p class="bodycopy">核心课建立能力;达到出口任务才进入下一阶段。</p>
<div class="slot"><div class="slot-title">✓ 第 1 课 · 你好,我是……</div><div class="slot-note">介绍姓名并回应问候</div></div>
<div class="slot selected"><div class="slot-title">第 2 课 · 拼读姓名 <span>进行中</span></div><div class="slot-note">听懂拼写,并说出自己的名字字母</div></div>
<div class="slot"><div class="slot-title">第 3 课 · 你好吗?</div><div class="slot-note">问候并表达简单状态</div></div>
<div class="slot"><div class="slot-title">第 4 课 · 数字与电话号码</div><div class="slot-note">说 010 与简单号码</div></div>
</div>
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem active" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
</section>
<section class="screen" data-screen="preview" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="home" type="button"></button><h2>第 2 课 · 拼读姓名</h2></div>
<div class="appbody"><div class="seg" aria-label="课前预热,第 1 项,共 4 项"><span class="on"></span><span></span><span></span><span></span></div><div class="eyebrow">先认识今天的词 · 约 2 分钟</div><h1>后面会遇到这些词。</h1><p class="bodycopy">先听一遍、知道意思就够了,不用马上背会。</p>
<div class="slot selected"><div class="slot-title">今天会遇到 4 个词和词块 <span>1 / 4</span></div><div class="word">spell</div><div class="ipa">/spel/ · 拼写</div><div class="audio"><button class="play" type="button"></button><div class="audio-line"><i style="width:42%"></i></div><small>慢速</small></div><div class="lookup-example">How do you spell that?<span>那个怎么拼?</span></div></div>
<div class="assist"><button type="button">再听一遍</button><button data-go="dictionary" type="button">查词</button></div><button class="primary" data-go="listen" type="button">认识了,进入课程</button><button class="text-action" data-go="listen" type="button">跳过,直接进入课程</button>
</div>
</section>
<section class="screen" data-screen="listen" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="preview" type="button"></button><h2>第 2 课 · 拼读姓名</h2></div>
<div class="appbody"><div class="seg" aria-label="第 1 步,共 5 步"><span class="on"></span><span></span><span></span><span></span><span></span></div><div class="eyebrow">听一听</div><h1>听懂别人怎么问。</h1>
<div class="audio"><button class="play" type="button"></button><div class="audio-line"><i></i></div><small>0:03</small></div>
<div class="sentence">How do you spell your <button class="lookup-word" data-go="dictionary" type="button">name</button>?</div><div class="lookup-hint"><i></i>点英文词或短语,随时查意思</div><p class="bodycopy" style="margin-top:12px">这句话是什么意思?</p>
<button class="choice active" type="button"><strong>你的名字怎么拼?</strong></button><button class="choice" type="button"><strong>你的名字是什么?</strong></button><button class="choice" type="button"><strong>你来自哪里?</strong></button><button class="primary" data-go="speak" type="button">检查并继续</button>
</div>
</section>
<section class="screen" data-screen="speak" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="listen" type="button"></button><h2>第 2 课 · 拼读姓名</h2></div>
<div class="appbody"><div class="seg" aria-label="第 2 步,共 5 步"><span class="on"></span><span class="on"></span><span></span><span></span><span></span></div><div class="eyebrow">跟读</div><h1>先听,再说。</h1>
<div class="word">S-H-E-N</div><div class="ipa">/es - eɪtʃ - iː - en/</div><div class="audio"><button class="play" type="button"></button><div class="audio-line"><i style="width:61%"></i></div><small>慢速</small></div>
<div class="slot warm"><div class="slot-title">发音提示</div><div class="slot-note">字母之间留一个短停顿。先清楚,不必快。</div></div>
<div class="record">按住说话 ● 录音跟读</div><button class="secondary" data-go="write" type="button">改用文字练习</button>
</div>
</section>
<section class="screen" data-screen="write" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="speak" type="button"></button><h2>第 2 课 · 拼读姓名</h2></div>
<div class="appbody"><div class="seg" aria-label="第 4 步,共 5 步"><span class="on"></span><span class="on"></span><span class="on"></span><span class="on"></span><span></span></div><div class="eyebrow">写一写</div><h1>把你的名字写完整。</h1>
<div class="sentence">My name is <span class="mark">Shen</span>.<br><span class="mark">S-H-E-N</span>.</div><div class="slot"><div class="slot-title">你的答案</div><div class="slot-note">My name is ______.<br>______ - ______ - ______ - ______.</div></div>
<button class="primary" data-go="dialogue" type="button">进入 AI 对话</button><button class="text-action" data-go="summary" type="button">稍后再练对话</button>
</div>
</section>
<section class="screen" data-screen="scenes" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><h2>AI 对话</h2></div>
<div class="appbody"><div class="eyebrow">按当前水平推荐</div><h1>选一个场景,开口练习。</h1><p class="bodycopy">每次 35 分钟,完成一个明确任务。</p>
<div class="slot selected"><div class="slot-title">初次见面 <span>A0</span></div><div class="slot-note">介绍姓名和来自哪里 · 4 轮</div><button class="primary" data-go="dialogue" type="button">开始对话</button></div>
<div class="slot"><div class="slot-title">认识新同学 <span>A0</span></div><div class="slot-note">问候并问对方姓名</div></div><div class="slot"><div class="slot-title">咖啡店 <span>A1</span></div><div class="slot-note">点一杯饮料 · 尚未解锁</div></div>
</div>
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem active" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
</section>
<section class="screen" data-screen="dialogue" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="scenes" type="button"></button><h2>初次见面 · 2 / 4</h2></div>
<div class="appbody"><div class="eyebrow">目标:说姓名和来自哪里</div><div class="chat"><div class="bubble ai">Hi! My name is Mia. Whats your <button class="lookup-word" data-go="dictionary" type="button">name</button>?</div><div class="bubble me">My name is Shen.</div><div class="bubble ai">Nice to meet you, Shen. Where are you <button class="lookup-word" data-go="dictionary" type="button">from</button>?</div></div>
<div class="lookup-hint"><i></i>不认识?点英文词或短语查意思</div><div class="assist"><button type="button">提示</button><button type="button">慢一点</button><button type="button">翻译</button><button type="button">重说</button></div><div class="record" data-go="confirm">按住说话 ● 或输入文字</div><button class="secondary" data-go="summary" type="button">完成对话示例</button>
</div>
</section>
<section class="screen" data-screen="confirm" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="dialogue" type="button"></button><h2>确认你说的话</h2></div>
<div class="appbody"><div class="eyebrow">我没有完全听清</div><h1>你想说的是这句吗?</h1><p class="bodycopy">识别不确定时,先确认,不会直接判成发音错误。</p>
<div class="slot selected"><div class="slot-title">Im from Hong Kong.</div><div class="slot-note">我来自香港。</div></div><button class="primary" data-go="summary" type="button">是的,发送这句话</button><button class="secondary" data-go="dialogue" type="button">重新说一次</button><button class="text-action" data-go="dialogue" type="button">修改文字</button>
</div>
</section>
<section class="screen" data-screen="summary" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbody"><div class="eyebrow">对话完成</div><h1>你完成了自我介绍!</h1><p class="bodycopy">你告诉了 Mia 你的姓名和来自哪里。</p>
<div class="checkline"><b></b><span><strong>说得很好</strong><br>My name is Shen.</span></div><div class="checkline"><b></b><span><strong>下次这样说</strong><br>Im from Hong Kong.</span></div>
<div class="slot warm"><div class="slot-title">现在再说一次</div><div class="slot-note">Im from Hong Kong.</div></div><button class="primary" data-go="review" type="button">录音重说</button><button class="secondary" data-go="home" type="button">回到首页</button>
</div>
</section>
<section class="screen" data-screen="review" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><h2>今天复习 · 3 项</h2></div>
<div class="appbody"><div class="eyebrow">昨天的对话</div><h1>再回答一次。</h1><p class="bodycopy">这次换成新的情境,也能用上同一句话。</p><div class="review-prompt">AIHi! Where are you from?</div><div class="assist"><button type="button">提示</button><button type="button">慢一点</button><button type="button">翻译</button></div><div class="record">按住说话 ● 或输入文字</div><p class="small" style="margin-top:13px">完成后还有 2 项 · 每项约 1 分钟</p></div>
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem active" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
</section>
<section class="screen" data-screen="progress" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><h2>我的进步</h2></div>
<div class="appbody"><div class="eyebrow">当前:A0 起步</div><h1>你已经能做到</h1><p class="bodycopy">进步以“能完成什么”呈现,而非单纯的分数。</p>
<div class="levelmap"><div class="level"><i class="on"></i><div><b>介绍姓名</b><span>完成 2 次对话</span></div><span></span></div><div class="level"><i class="on"></i><div><b>说来自哪里</b><span>已加入复习</span></div><span></span></div><div class="level"><i></i><div><b>拼读电话号码</b><span>下一项核心练习</span></div><span></span></div></div>
<div class="slot"><div class="slot-title">A0 当前掌握 <span>准备出口挑战</span></div><div class="meter"><span style="width:30%"></span></div></div><button class="secondary" data-go="settings" type="button">调整学习设置</button>
</div>
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem active" type="button"><i class="nav-dot"></i>我的</button></nav>
</section>
<section class="screen" data-screen="settings" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button"></button><h2>学习设置</h2></div>
<div class="appbody"><div class="setting"><div>AI 对话服务<br><span>Gemini API · 可切换兼容反代</span></div><b></b></div><div class="setting"><div>每日学习时间<br><span>20 分钟</span></div><b></b></div><div class="setting"><div>英文朗读速度<br><span>慢速</span></div><b></b></div><div class="setting"><div>默认显示中文提示<br><span>A0 阶段开启</span></div><div class="toggle"><i></i></div></div><div class="setting"><div>保存原始录音<br><span>仅保存在本机</span></div><div class="toggle"><i></i></div></div><button class="secondary" data-go="home" type="button">清空本机练习记录</button></div>
</section>
<section class="screen" data-screen="dictionary" hidden>
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="dialogue" type="button"></button><h2>初次见面 · 2 / 4</h2></div>
<div class="lookup-stage"><div class="lookup-context">AINice to meet you, Shen. Where are you <span class="mark">from</span>?</div><div class="lookup-sheet"><div class="lookup-handle"></div><div class="lookup-head"><div><div class="word">from</div><div class="ipa">/frʌm/ · 介词</div></div><button class="lookup-close" data-go="dialogue" aria-label="关闭查词卡" type="button">×</button></div><p class="meaning">来自;从……</p><div class="audio"><button class="play" type="button"></button><div class="audio-line"><i style="width:45%"></i></div><small>慢速</small></div><div class="lookup-example">Im from Hong Kong.<span>我来自香港。</span></div><button class="primary" type="button">加入复习</button><button class="text-action" data-go="dialogue" type="button">关闭,回到对话</button></div></div>
</section>
<section class="screen" data-screen="mac" hidden>
<div class="status"><span>开口英语</span><span>◉ Shen</span></div>
<div class="mac-body"><aside class="mac-side"><b>开口英语</b><span class="on">首页</span><span>学习</span><span>对话</span><span>复习</span><span>我的</span></aside><main class="mac-main"><div class="eyebrow">早上好,Shen</div><h1>今天,说 3 句英语。</h1><p class="bodycopy">Mac 复用手机端的课程与进度,本机也能独立练习。</p><div class="mac-grid"><div class="slot selected"><div class="slot-title">第 2 课 · 拼读姓名</div><div class="slot-note">12 分钟 · 先认识 4 个词 · 听说读写</div><button class="primary" data-go="preview" type="button">继续学习</button></div><div class="slot"><div class="slot-title">AI 对话 · 初次见面</div><div class="slot-note">3 分钟 · 介绍姓名与地点</div><button class="secondary" data-go="dialogue" type="button">开始对话</button></div></div><div class="slot" style="margin-top:12px"><div class="slot-title">今天复习 · 3 项</div><div class="slot-note">昨天的关键句将在新情境中再次练习。</div></div></main></div>
</section>
</div>
</main>
<aside class="panel flow-panel" aria-label="原型流程说明">
<p class="legend-title">M1 流程</p><div class="flow"><div class="flow-row"><strong>首次使用</strong>目标设置 → 起点定位 → 首页</div><div class="flow-row active"><strong>日常学习</strong>首页 → 词汇预热 → 课程 → AI 对话 → 总结</div><div class="flow-row"><strong>查词帮助</strong>点词/短语 → 查词卡 → 回到原步骤</div><div class="flow-row"><strong>纠错保护</strong>低置信转写 → 确认 / 修改 / 重说</div><div class="flow-row"><strong>巩固</strong>总结 → 次日微对话复习</div><div class="flow-row"><strong>设备</strong>手机优先;Mac 使用更宽的同一学习结构</div></div><p class="legend-title" style="margin-top:20px">设计原则</p><span class="chip">一个主任务</span><span class="chip">预热不算掌握</span><span class="chip">短语优先查词</span><span class="chip">口音可理解即接受</span><span class="chip">一次只纠正一个点</span><span class="chip">语音失败可改文字</span>
</aside>
</div>
<script>
(() => {
const root = document.getElementById('learning-app-prototype');
const phone = root.querySelector('#prototype-phone');
const screens = [...root.querySelectorAll('.screen')];
const links = [...root.querySelectorAll('[data-screen-target]')];
const show = name => {
screens.forEach(screen => { screen.hidden = screen.dataset.screen !== name; });
links.forEach(link => link.setAttribute('aria-pressed', String(link.dataset.screenTarget === name)));
phone.classList.toggle('mac', name === 'mac');
};
links.forEach(link => link.addEventListener('click', () => show(link.dataset.screenTarget)));
root.querySelectorAll('[data-go]').forEach(button => button.addEventListener('click', () => show(button.dataset.go)));
show('home');
})();
</script>
</div>
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# kouyu_english
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.shen.kouyu_english"
compileSdk = flutter.compileSdkVersion
// Speech and storage plugins require NDK 28.2. This complete local
// toolchain satisfies all plugin requirements for reproducible builds.
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.shen.kouyu_english"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="kouyu_english"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.shen.kouyu_english
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
@@ -0,0 +1,735 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
883CD9887692F80D3FB86906 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AC5E6CF32F60E96545899A44 /* Pods_RunnerTests.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
DD20BA89D8480BC4F775A24F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 428EF87395C0C527F283B8C8 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0801B110D371647107A13A73 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
428EF87395C0C527F283B8C8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
42DE5B6C439FF8CF028C2BF2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
561CA8C2E1FD76E44B1CFD4F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AC5E6CF32F60E96545899A44 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D1C05593344E00B03463107A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
DD3E8F24059F5FA299959118 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
F6243600BB8FAB55AA75C8EE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
2D5E6CE801999C65C98D143D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
883CD9887692F80D3FB86906 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DD20BA89D8480BC4F775A24F /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
DADC5D95F208953F58E7BDBF /* Pods */,
F3BED9287B2BA9C107D6A9B2 /* Frameworks */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
DADC5D95F208953F58E7BDBF /* Pods */ = {
isa = PBXGroup;
children = (
42DE5B6C439FF8CF028C2BF2 /* Pods-Runner.debug.xcconfig */,
F6243600BB8FAB55AA75C8EE /* Pods-Runner.release.xcconfig */,
561CA8C2E1FD76E44B1CFD4F /* Pods-Runner.profile.xcconfig */,
DD3E8F24059F5FA299959118 /* Pods-RunnerTests.debug.xcconfig */,
0801B110D371647107A13A73 /* Pods-RunnerTests.release.xcconfig */,
D1C05593344E00B03463107A /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
F3BED9287B2BA9C107D6A9B2 /* Frameworks */ = {
isa = PBXGroup;
children = (
428EF87395C0C527F283B8C8 /* Pods_Runner.framework */,
AC5E6CF32F60E96545899A44 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
B3D4280A6685B3D080834DBC /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
2D5E6CE801999C65C98D143D /* Frameworks */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
6C143D0B0D7C18573721566D /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
818DE85F21538DCB4820F5A5 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
6C143D0B0D7C18573721566D /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
818DE85F21538DCB4820F5A5 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
B3D4280A6685B3D080834DBC /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MG4Z7FU83W;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = DD3E8F24059F5FA299959118 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0801B110D371647107A13A73 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D1C05593344E00B03463107A /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MG4Z7FU83W;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MG4Z7FU83W;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kouyu English</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>kouyu_english</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>用于录制和转写你的英语练习,供你回听或确认;录音只保存在本机。</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>用于识别英语练习内容;你可以改用文字输入。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+194
View File
@@ -0,0 +1,194 @@
/// Frozen A0 v1 upgrade denominator from A0-STAGE-STANDARD.md.
/// IDs are intentionally separate from generated wording and never change
/// when a course uses an accepted slot value or contraction.
final Map<String, String> a0CoreItems = Map.unmodifiable({
for (final entry in _numbered('A0-W', const [
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
'book',
'phone',
'pen',
'bag',
'key',
'water',
'mother',
'father',
'sister',
'brother',
'coffee',
'tea',
'music',
'movies',
'good',
'okay',
'tired',
'hello',
'hi',
'yes',
'no',
'thanks',
]))
entry.key: entry.value,
for (final entry in _numbered('A0-P', const [
"I'm [name].",
"What's your name?",
'Nice to meet you.',
'How do you spell that?',
'How are you?',
"I'm [state].",
"What's your phone number?",
'My number is [digits].',
"What's this?",
"It's a [object].",
'Where are you from?',
"I'm from [place].",
'This is my [person].',
'What day is it today?',
"It's [weekday].",
'What time is it?',
"It's [hour] o'clock.",
'I like [thing].',
'Do you like [thing]?',
'Please say that again.',
]))
entry.key: entry.value,
});
Iterable<MapEntry<String, String>> _numbered(
String prefix,
List<String> values,
) sync* {
for (var index = 0; index < values.length; index++) {
yield MapEntry(
'$prefix${(index + 1).toString().padLeft(2, '0')}',
values[index],
);
}
}
class CoreReviewTemplate {
const CoreReviewTemplate({
required this.prompt,
required this.hint,
required this.skill,
});
final String prompt;
final String hint;
final String skill;
}
/// Offline, reviewed prompts keep initial reviews usable without an AI service.
/// The prompt supplies a meaning or situation, never the English target.
CoreReviewTemplate coreReviewTemplate(String id) {
final target = a0CoreItems[id] ?? id;
const meanings = {
'A0-W01': '',
'A0-W02': '',
'A0-W03': '',
'A0-W04': '',
'A0-W05': '',
'A0-W06': '',
'A0-W07': '',
'A0-W08': '',
'A0-W09': '',
'A0-W10': '',
'A0-W11': '',
'A0-W12': '星期一',
'A0-W13': '星期二',
'A0-W14': '星期三',
'A0-W15': '星期四',
'A0-W16': '星期五',
'A0-W17': '星期六',
'A0-W18': '星期日',
'A0-W19': '',
'A0-W20': '电话',
'A0-W21': '',
'A0-W22': '',
'A0-W23': '钥匙',
'A0-W24': '',
'A0-W25': '妈妈',
'A0-W26': '爸爸',
'A0-W27': '姐妹',
'A0-W28': '兄弟',
'A0-W29': '咖啡',
'A0-W30': '',
'A0-W31': '音乐',
'A0-W32': '电影',
'A0-W33': '',
'A0-W34': '还可以',
'A0-W35': '',
'A0-W36': '你好',
'A0-W37': '',
'A0-W38': '',
'A0-W39': '',
'A0-W40': '谢谢',
};
final phrasePrompt = switch (id) {
'A0-P01' => '向新同学介绍你的名字。',
'A0-P02' => '问对方叫什么名字。',
'A0-P03' => '对初次见面的人说“很高兴认识你”。',
'A0-P04' => '问对方一个词怎样拼写。',
'A0-P05' => '问对方今天怎么样。',
'A0-P06' => '说出你今天的状态。',
'A0-P07' => '问对方的电话号码。',
'A0-P08' => '报出一个虚构的三位号码。',
'A0-P09' => '指着身边的东西问“这是什么”。',
'A0-P10' => '说出“这是一件物品”。',
'A0-P11' => '问对方来自哪里。',
'A0-P12' => '说出你来自哪里。',
'A0-P13' => '介绍一位家人。',
'A0-P14' => '问今天星期几。',
'A0-P15' => '说出今天星期几。',
'A0-P16' => '问现在几点。',
'A0-P17' => '说出一个整点时间。',
'A0-P18' => '说出你喜欢的一样东西。',
'A0-P19' => '问对方是否喜欢一样东西。',
'A0-P20' => '请对方再说一次。',
_ => null,
};
if (phrasePrompt != null) {
return CoreReviewTemplate(
prompt: phrasePrompt,
hint: target,
skill: '回忆表达',
);
}
return CoreReviewTemplate(
prompt: '把“${meanings[id] ?? '这个已学词'}”写成英文。',
hint: target,
skill: '词汇回忆',
);
}
/// Rotates an approved offline prompt family without changing the core target
/// ID. These are a safe fallback when a dynamic AI variant is unavailable.
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
final base = coreReviewTemplate(id);
final lead = switch (variantIndex % 3) {
0 => '换一个人物或地点,',
1 => '在新的生活情境中,',
_ => '不看上次答案,',
};
return CoreReviewTemplate(
prompt: '$lead${base.prompt}',
hint: base.hint,
skill: base.skill,
);
}
+730
View File
@@ -0,0 +1,730 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'models.dart';
import 'generated_content.dart';
import 'a0_core.dart';
class AiConnectionResult {
const AiConnectionResult({required this.ok, required this.message});
final bool ok;
final String message;
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
await _secureStorage.delete(key: _keyName);
} else {
await _secureStorage.write(key: _keyName, value: value.trim());
}
}
Future<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
Future<String?> temporaryDefinition({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
if (provider == AiProviderType.mock ||
text.length > 120 ||
text.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
uri.scheme != 'https') {
return null;
}
const instruction =
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': '$instruction\nText: $text'},
],
},
],
'generationConfig': {
'maxOutputTokens': 100,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
'max_tokens': 100,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final raw = _extractResponseContent(provider, response.body);
final data = raw == null ? null : jsonDecode(raw);
final definition = data is Map ? data['definition'] : null;
return definition is String &&
definition.trim().isNotEmpty &&
definition.length <= 160
? definition.trim()
: null;
} catch (_) {
return null;
}
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
required String model,
}) async {
if (provider == AiProviderType.mock) {
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
}
const probe =
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
ok: false,
message: '请填写 Base URL 和模型名称。',
);
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
}
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': probe},
],
},
],
'generationConfig': {
'maxOutputTokens': 80,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式。',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
);
} catch (_) {
return const AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
);
}
}
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
],
},
'contents': history
.map(
(turn) => {
'role': turn['role'] == 'assistant'
? 'model'
: 'user',
'parts': [
{'text': turn['content']},
],
},
)
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
'temperature': 0.3,
'max_tokens': 60,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
return _decodeDialogueResponse(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return null;
}
}
/// Generates only a bounded variant of an existing review target. A network
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
Future<GeneratedReviewVariant?> generateReviewVariant({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String basePrompt,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $answer''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 240,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
final instruction =
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
'targetItemIds': lesson.targetItemIds,
'tasks': lesson.tasks
.map(
(task) => {
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'answerSpec': {
'requiredAnyPhrases': task.localAnswerSpec.requiredAnyPhrases,
'acceptedAnswers': task.localAnswerSpec.acceptedAnswers,
'forbiddenPhrases': task.localAnswerSpec.forbiddenPhrases,
},
},
)
.toList(),
});
final instruction =
'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 120,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return false;
}
return _decodeLessonAudit(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return false;
}
}
bool _decodeLessonAudit(String? raw) {
if (raw == null || raw.length > 600) return false;
try {
final data = jsonDecode(raw);
return data is Map<String, dynamic> &&
data.length == 3 &&
data['schemaVersion'] == 'lesson-audit-1' &&
data['approved'] == true &&
data['reason'] is String &&
(data['reason'] as String).length <= 160;
} catch (_) {
return false;
}
}
String? _extractResponseContent(AiProviderType provider, String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
if (provider == AiProviderType.gemini) {
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
final candidateContent = candidate?['content'] as Map?;
final parts = candidateContent?['parts'] as List?;
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
} catch (_) {
return null;
}
}
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
return null;
}
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
}
return DialogueAiResponse(
reply: reply.trim(),
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
);
} catch (_) {
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
abstract final class AppColors {
static const paper = Color(0xFFF5F7F3);
static const surface = Colors.white;
static const surfaceMuted = Color(0xFFF1F5F0);
static const ink = Color(0xFF19211B);
static const muted = Color(0xFF647268);
static const line = Color(0xFFD8E1D9);
static const green = Color(0xFF176B46);
static const softGreen = Color(0xFFE2F3E8);
static const warm = Color(0xFFFFF0E3);
static const warmInk = Color(0xFF9E4C18);
}
ThemeData buildAppTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: AppColors.green,
brightness: Brightness.light,
).copyWith(
surface: AppColors.surface,
onSurface: AppColors.ink,
primary: AppColors.green,
onPrimary: Colors.white,
outline: AppColors.line,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.paper,
fontFamily: 'PingFang SC',
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.ink,
elevation: 0,
centerTitle: false,
),
textTheme: const TextTheme(
headlineMedium: TextStyle(
color: AppColors.ink,
fontSize: 26,
height: 1.25,
fontWeight: FontWeight.w600,
letterSpacing: -0.6,
),
titleLarge: TextStyle(
color: AppColors.ink,
fontSize: 18,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(color: AppColors.ink, fontSize: 16, height: 1.5),
bodyMedium: TextStyle(color: AppColors.muted, fontSize: 14, height: 1.5),
),
);
}
+302
View File
@@ -0,0 +1,302 @@
import 'models.dart';
import 'writing_feedback.dart';
class AssessmentTask {
const AssessmentTask({
required this.id,
required this.skill,
required this.prompt,
this.audio,
this.choices = const [],
this.answerIndex,
this.requiredWords = const [],
required this.abilityId,
});
final String id;
final AssessmentSkill skill;
final String prompt;
final String? audio;
final List<String> choices;
final int? answerIndex;
final List<String> requiredWords;
final String abilityId;
}
class AssessmentPack {
const AssessmentPack({required this.id, required this.tasks});
final String id;
final List<AssessmentTask> tasks;
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
tasks.where((task) => task.skill == skill).toList();
}
final a0AssessmentPacks = [
AssessmentPack(
id: 'A0-E1',
tasks: _tasks(
'A',
const [
'Hello. Im Mia.|自我介绍|买东西|问时间|0',
'How do you spell your name?|问姓名拼写|问地点|问时间|0',
'Im tired today.|很好|累|来自哪里|1',
'seven|6|7|8|1',
'one-three-eight|138|183|318|0',
'Its a key.|书|钥匙|包|1',
'Im from Hong Kong.|香港|伦敦|北京|0',
'This is my mother.|妈妈|姐姐|朋友|0',
'It is Monday.|星期一|星期二|星期三|0',
'I like tea.|茶|咖啡|电影|0',
],
const [
'Mia: Im from Hong Kong.\nShen: Im good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0',
'Mia: It is Monday.\nShen: It is three oclock.|几点?|three|one|0',
'Mia: This is my brother.\nShen: Nice to meet you.|谁是家人?|brother|mother|0',
'Mia: I like music.\nShen: I like tea.|Shen 喜欢什么?|tea|music|0',
'Mia: Whats this?\nShen: Its a pen.|这是什么?|pen|bag|0',
],
const [
'用英语介绍自己。|i,m',
'用英语说明你来自哪里。|i,m,from',
'用英语说一个喜好。|i,like',
'用英语说“这是一支笔”。|it,s,pen',
'用英语说明星期或整点。|it,s',
],
const [
'介绍姓名、地点、状态/喜好并反问。|hello,i,m,from',
'完整拼读一个四字母姓名。|a,l,e,x',
'说一个三位虚拟号码。|one,three,eight',
'介绍一位家人。|this,my',
'说明星期。|it,s,monday',
'说明整点。|it,s,o,clock',
'请求对方重复或放慢。|please',
'说出一个物品。|it,s',
],
),
),
AssessmentPack(
id: 'A0-E2',
tasks: _tasks(
'B',
const [
'Hi. My name is Leo.|自我介绍|买东西|问号码|0',
'How do you spell that?|问姓名拼写|问喜好|问家人|0',
'Im okay.|很好|还可以|累|1',
'four|3|4|5|1',
'two-nine-six|296|269|926|0',
'Its a bag.|笔|包|水|1',
'Im from London.|伦敦|香港|北京|0',
'This is my sister.|姐姐|妈妈|朋友|0',
'It is Friday.|星期五|星期一|星期日|0',
'I like coffee.|茶|咖啡|音乐|1',
],
const [
'Mia: Im from London.\nLeo: Im okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0',
'Mia: It is Friday.\nLeo: It is six oclock.|几点?|six|three|0',
'Mia: This is my father.\nLeo: Hi!|谁是家人?|father|brother|0',
'Mia: I like movies.\nLeo: I like coffee.|Leo 喜欢什么?|coffee|movies|0',
'Mia: Whats this?\nLeo: Its water.|这是什么?|water|book|0',
],
const [
'用英语介绍自己。|my,name,is',
'用英语说明你来自哪里。|i,m,from',
'用英语说一个喜好。|i,like',
'用英语说“这是一个包”。|it,s,bag',
'用英语说明星期或整点。|it,s',
],
const [
'介绍姓名、地点、状态/喜好并反问。|hi,i,m,from',
'完整拼读一个四字母姓名。|l,e,o,n',
'说一个三位虚拟号码。|two,nine,six',
'介绍一位家人。|this,my',
'说明星期。|it,s,friday',
'说明整点。|it,s,o,clock',
'请求对方重复或放慢。|please',
'说出一个物品。|it,s',
],
),
),
];
/// Replacement packs use different people, places, numbers, days and likes.
/// They keep the same assessed ability but are not immediate replays.
final a0ReplacementPacks = [
_replacementPack(a0AssessmentPacks[0], 'A0-E1R', const {
'Mia': 'Nora',
'Shen': 'Kai',
'Hong Kong': 'London',
'Monday': 'Friday',
'one-three-eight': 'two-four-seven',
'138': '247',
'three oclock': 'six oclock',
'tea': 'coffee',
'key': 'bag',
'mother': 'father',
}),
_replacementPack(a0AssessmentPacks[1], 'A0-E2R', const {
'Mia': 'Ava',
'Leo': 'Bo',
'London': 'Hong Kong',
'Friday': 'Tuesday',
'two-nine-six': 'five-one-four',
'296': '514',
'six oclock': 'two oclock',
'coffee': 'music',
'bag': 'book',
'sister': 'brother',
}),
];
AssessmentPack? replacementFor(String packId) => switch (packId) {
'A0-E1' => a0ReplacementPacks[0],
'A0-E2' => a0ReplacementPacks[1],
_ => null,
};
/// Local checks for the frozen A0 exit tasks. They accept variable names and
/// places, but require the communicative information named by each task.
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool phrase(String value) => text.contains(value);
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final position = int.tryParse(
RegExp(r'(\d+)$').firstMatch(task.id)?.group(1) ?? '',
);
if (position == null) return false;
if (task.skill == AssessmentSkill.writing) {
const lessonIds = ['a0-01', 'a0-06', 'a0-09', 'a0-05', 'a0-08'];
return position >= 1 &&
position <= lessonIds.length &&
WritingFeedback.check(lessonIds[position - 1], input).complete;
}
if (task.skill != AssessmentSkill.speaking) return false;
return switch (position) {
1 =>
hasAny(['hello', 'hi']) &&
introduction &&
has('from') &&
(has('like') || hasAny(['good', 'okay', 'tired'])) &&
hasAny(['what', 'where', 'how', 'do']),
2 => RegExp(r'[a-z](?:[ -]?[a-z]){3,}').hasMatch(text),
3 => numberWords >= 3,
4 =>
phrase('this is my') && hasAny(['mother', 'father', 'sister', 'brother']),
5 =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
6 =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
7 => has('please') && hasAny(['say', 'speak', 'repeat', 'slow', 'slowly']),
8 => itIs && hasAny(['book', 'pen', 'bag', 'key', 'water']),
_ => false,
};
}
AssessmentPack _replacementPack(
AssessmentPack source,
String id,
Map<String, String> values,
) {
String replace(String value) {
var result = value;
for (final entry in values.entries) {
result = result.replaceAll(entry.key, entry.value);
}
return result;
}
return AssessmentPack(
id: id,
tasks: source.tasks
.map(
(task) => AssessmentTask(
id: task.id.replaceFirst(
source.id.split('-').last,
id.split('-').last,
),
skill: task.skill,
prompt: replace(task.prompt),
audio: task.audio == null ? null : replace(task.audio!),
choices: task.choices.map(replace).toList(),
answerIndex: task.answerIndex,
requiredWords: task.requiredWords.map(replace).toList(),
abilityId: task.abilityId,
),
)
.toList(),
);
}
List<AssessmentTask> _tasks(
String version,
List<String> listening,
List<String> reading,
List<String> writing,
List<String> speaking,
) {
AssessmentTask choice(String raw, AssessmentSkill skill, int index) {
final fields = raw.split('|');
return AssessmentTask(
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
skill: skill,
audio: skill == AssessmentSkill.listening ? fields[0] : null,
prompt: skill == AssessmentSkill.listening ? '选择正确答案' : fields[0],
choices: skill == AssessmentSkill.listening
? fields.sublist(1, 4)
: fields.sublist(1, 3),
answerIndex: int.parse(fields.last),
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
);
}
AssessmentTask open(String raw, AssessmentSkill skill, int index) {
final fields = raw.split('|');
return AssessmentTask(
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
skill: skill,
prompt: fields[0],
requiredWords: fields[1].split(','),
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
);
}
return [
...List.generate(
listening.length,
(i) => choice(listening[i], AssessmentSkill.listening, i),
),
...List.generate(
reading.length,
(i) => choice(reading[i], AssessmentSkill.reading, i),
),
...List.generate(
writing.length,
(i) => open(writing[i], AssessmentSkill.writing, i),
),
...List.generate(
speaking.length,
(i) => open(speaking[i], AssessmentSkill.speaking, i),
),
];
}
@@ -0,0 +1,580 @@
import 'dart:convert';
import 'a0_core.dart';
import 'models.dart';
/// A bounded AI-authored variation of an existing review item. It never
/// creates a core identity and is only usable after local schema validation.
class GeneratedReviewVariant {
const GeneratedReviewVariant({
required this.variantId,
required this.targetItemId,
required this.prompt,
required this.expectedAnswer,
});
final String variantId;
final String targetItemId;
final String prompt;
final String expectedAnswer;
}
/// A bounded, AI-authored adaptive lesson. It is deliberately a data object,
/// not a free-form chat response: the client can validate every target and
/// task before deciding whether to cache or display it.
class GeneratedLessonTask {
const GeneratedLessonTask({
required this.taskId,
required this.skill,
required this.type,
required this.prompt,
required this.stimulus,
required this.answer,
required this.targetItemIds,
this.answerSpec,
});
final String taskId;
final String skill;
final String type;
final String prompt;
final String stimulus;
final String answer;
final List<String> targetItemIds;
final GeneratedAnswerSpec? answerSpec;
GeneratedAnswerSpec get localAnswerSpec =>
answerSpec ?? GeneratedAnswerSpec.referenceOnly(answer);
}
/// A finite, client-verifiable boundary for a generated answer. It supports
/// specific accepted variants and required information slots without handing
/// an open-ended semantic judgement to the AI provider.
class GeneratedAnswerSpec {
const GeneratedAnswerSpec({
required this.requiredAnyPhrases,
required this.acceptedAnswers,
this.forbiddenPhrases = const [],
});
factory GeneratedAnswerSpec.referenceOnly(String answer) =>
GeneratedAnswerSpec(
requiredAnyPhrases: [
[answer],
],
acceptedAnswers: [answer],
);
final List<List<String>> requiredAnyPhrases;
final List<String> acceptedAnswers;
final List<String> forbiddenPhrases;
}
class GeneratedLesson {
const GeneratedLesson({
required this.lessonId,
required this.revision,
required this.stageVersion,
required this.abilityIds,
required this.prerequisiteIds,
required this.targetItemIds,
required this.receptiveChunks,
required this.previewItemIds,
required this.estimatedMinutes,
required this.tasks,
});
final String lessonId;
final int revision;
final String stageVersion;
final List<String> abilityIds;
final List<String> prerequisiteIds;
final List<String> targetItemIds;
final List<String> receptiveChunks;
final List<String> previewItemIds;
final int estimatedMinutes;
final List<GeneratedLessonTask> tasks;
}
/// Conservative client-side check for audited, bounded adaptive tasks. It
/// accepts explicitly approved phrases or all required information slots,
/// ignoring harmless case/punctuation variation and allowing extra words.
/// This is intentionally unavailable for free-form AI content and never
/// trusts a model's own "correct" claim.
bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
final actual = _normalizeAnswer(answer);
if (actual.isEmpty) return false;
final spec = task.localAnswerSpec;
if (spec.forbiddenPhrases.any((phrase) => _containsPhrase(actual, phrase))) {
return false;
}
final accepted = spec.acceptedAnswers.any(
(phrase) => _containsPhrase(actual, phrase),
);
final slots =
spec.requiredAnyPhrases.isNotEmpty &&
spec.requiredAnyPhrases.every(
(alternatives) =>
alternatives.any((phrase) => _containsPhrase(actual, phrase)),
);
return accepted || slots;
}
String _normalizeAnswer(String value) => value
.toLowerCase()
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
bool _containsPhrase(String normalizedAnswer, String phrase) {
final normalizedPhrase = _normalizeAnswer(phrase);
return normalizedPhrase.isNotEmpty &&
' $normalizedAnswer '.contains(' $normalizedPhrase ');
}
GeneratedReviewVariant? decodeGeneratedReviewVariant(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 1600) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 5) return null;
if (data['schemaVersion'] != 'review-variant-1') return null;
final variantId = data['variantId'];
final targetItemId = data['targetItemId'];
final prompt = data['prompt'];
final expectedAnswer = data['expectedAnswer'];
if (variantId is! String ||
targetItemId is! String ||
prompt is! String ||
expectedAnswer is! String ||
variantId.length > 80 ||
prompt.trim().isEmpty ||
prompt.length > 220 ||
expectedAnswer.trim().isEmpty ||
expectedAnswer.length > 120 ||
targetItemId != expectedTargetItemId ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
return GeneratedReviewVariant(
variantId: variantId,
targetItemId: targetItemId,
prompt: prompt.trim(),
expectedAnswer: expectedAnswer.trim(),
);
} catch (_) {
return null;
}
}
GeneratedLesson? decodeGeneratedLesson(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 12000) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic>) return null;
const required = {
'schemaVersion',
'lessonId',
'revision',
'stageVersion',
'source',
'status',
'abilityIds',
'prerequisiteIds',
'targetItemIds',
'receptiveChunks',
'newItemIds',
'previewItemIds',
'estimatedMinutes',
'tasks',
};
if (data.length != required.length ||
!data.keys.every(required.contains) ||
!(data['schemaVersion'] == 'lesson-1' ||
data['schemaVersion'] == 'lesson-2') ||
data['source'] != 'aiGenerated' ||
data['status'] != 'validated') {
return null;
}
final lessonId = data['lessonId'];
final revision = data['revision'];
final stageVersion = data['stageVersion'];
final estimatedMinutes = data['estimatedMinutes'];
final abilityIds = _stringList(data['abilityIds'], max: 4);
final prerequisites = _stringList(data['prerequisiteIds'], max: 8);
final targets = _stringList(data['targetItemIds'], max: 8);
final chunks = _stringList(data['receptiveChunks'], max: 2);
final newItems = _stringList(data['newItemIds'], max: 8);
final preview = _stringList(data['previewItemIds'], max: 8);
final rawTasks = data['tasks'];
if (lessonId is! String ||
!RegExp(r'^ai-a0-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
revision is! int ||
revision < 1 ||
stageVersion != 'A0-1.0' ||
estimatedMinutes is! int ||
estimatedMinutes < 8 ||
estimatedMinutes > 15 ||
abilityIds == null ||
prerequisites == null ||
targets == null ||
chunks == null ||
newItems == null ||
preview == null ||
rawTasks is! List ||
targets.length != 1 ||
targets.single != expectedTargetItemId ||
!a0CoreItems.containsKey(expectedTargetItemId) ||
newItems.isNotEmpty ||
preview.isNotEmpty ||
chunks.isNotEmpty ||
rawTasks.length != 4) {
return null;
}
final tasks = <GeneratedLessonTask>[];
const skills = {'listening', 'speaking', 'reading', 'writing'};
const types = {'listenChoice', 'repeat', 'readAnswer', 'writeAnswer'};
for (final rawTask in rawTasks) {
if (rawTask is! Map<String, dynamic>) return null;
const taskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
'answerSpec',
};
const legacyTaskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
};
final allowedTaskKeys = data['schemaVersion'] == 'lesson-2'
? taskKeys
: legacyTaskKeys;
if (rawTask.length != allowedTaskKeys.length ||
!rawTask.keys.every(allowedTaskKeys.contains)) {
return null;
}
final taskId = rawTask['taskId'];
final skill = rawTask['skill'];
final type = rawTask['type'];
final prompt = rawTask['prompt'];
final stimulus = rawTask['stimulus'];
final answer = rawTask['answer'];
final taskTargets = _stringList(rawTask['targetItemIds'], max: 1);
final answerSpec = _decodeAnswerSpec(
rawTask['answerSpec'],
fallbackAnswer: answer is String ? answer : '',
required: data['schemaVersion'] == 'lesson-2',
);
if (taskId is! String ||
taskId.length > 80 ||
skill is! String ||
!skills.contains(skill) ||
type is! String ||
!types.contains(type) ||
prompt is! String ||
prompt.trim().isEmpty ||
prompt.length > 260 ||
stimulus is! String ||
stimulus.trim().isEmpty ||
stimulus.length > 260 ||
answer is! String ||
answer.trim().isEmpty ||
answer.length > 160 ||
!_usesOnlyA0GeneratedWords(stimulus) ||
!_usesOnlyA0GeneratedWords(answer) ||
answerSpec == null ||
taskTargets == null ||
taskTargets.length != 1 ||
taskTargets.single != expectedTargetItemId) {
return null;
}
tasks.add(
GeneratedLessonTask(
taskId: taskId,
skill: skill,
type: type,
prompt: prompt.trim(),
stimulus: stimulus.trim(),
answer: answer.trim(),
targetItemIds: taskTargets,
answerSpec: answerSpec,
),
);
}
if (tasks.map((task) => task.skill).toSet().length != 4) return null;
return GeneratedLesson(
lessonId: lessonId,
revision: revision,
stageVersion: stageVersion,
abilityIds: abilityIds,
prerequisiteIds: prerequisites,
targetItemIds: targets,
receptiveChunks: chunks,
previewItemIds: preview,
estimatedMinutes: estimatedMinutes,
tasks: tasks,
);
} catch (_) {
return null;
}
}
String encodeGeneratedLesson(GeneratedLesson lesson) => jsonEncode({
'schemaVersion': 'lesson-2',
'lessonId': lesson.lessonId,
'revision': lesson.revision,
'stageVersion': lesson.stageVersion,
'source': 'aiGenerated',
'status': 'validated',
'abilityIds': lesson.abilityIds,
'prerequisiteIds': lesson.prerequisiteIds,
'targetItemIds': lesson.targetItemIds,
'receptiveChunks': lesson.receptiveChunks,
'newItemIds': const [],
'previewItemIds': lesson.previewItemIds,
'estimatedMinutes': lesson.estimatedMinutes,
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'targetItemIds': task.targetItemIds,
'answerSpec': _encodeAnswerSpec(task.localAnswerSpec),
},
)
.toList(),
});
Map<String, dynamic> _encodeAnswerSpec(GeneratedAnswerSpec spec) => {
'requiredAnyPhrases': spec.requiredAnyPhrases,
'acceptedAnswers': spec.acceptedAnswers,
'forbiddenPhrases': spec.forbiddenPhrases,
};
GeneratedAnswerSpec? _decodeAnswerSpec(
Object? value, {
required String fallbackAnswer,
required bool required,
}) {
if (value == null && !required && fallbackAnswer.trim().isNotEmpty) {
return GeneratedAnswerSpec.referenceOnly(fallbackAnswer);
}
if (value is! Map<String, dynamic> || value.length != 3) return null;
const keys = {'requiredAnyPhrases', 'acceptedAnswers', 'forbiddenPhrases'};
if (!value.keys.every(keys.contains)) return null;
final accepted = _stringList(value['acceptedAnswers'], max: 4);
final forbidden = _stringList(value['forbiddenPhrases'], max: 4);
final rawGroups = value['requiredAnyPhrases'];
if (accepted == null ||
accepted.isEmpty ||
forbidden == null ||
rawGroups is! List ||
rawGroups.isEmpty ||
rawGroups.length > 4) {
return null;
}
final groups = <List<String>>[];
for (final rawGroup in rawGroups) {
final group = _stringList(rawGroup, max: 4);
if (group == null || group.isEmpty) return null;
groups.add(group);
}
final phrases = [
...accepted,
...forbidden,
...groups.expand((group) => group),
];
if (phrases.any((phrase) => !_usesOnlyA0GeneratedWords(phrase))) return null;
final spec = GeneratedAnswerSpec(
requiredAnyPhrases: groups,
acceptedAnswers: accepted,
forbiddenPhrases: forbidden,
);
return matchesAdaptiveLessonAnswer(
GeneratedLessonTask(
taskId: 'validation',
skill: 'writing',
type: 'writeAnswer',
prompt: '验证',
stimulus: fallbackAnswer,
answer: fallbackAnswer,
targetItemIds: const ['A0-P01'],
answerSpec: spec,
),
fallbackAnswer,
)
? spec
: null;
}
/// Dynamic A0 reinforcement must not smuggle in a harder English word via a
/// stimulus or answer. Prompts may be Chinese; only learner-facing English is
/// constrained here. Names and places are fixed, non-personal demo values.
bool _usesOnlyA0GeneratedWords(String value) {
final words = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?")
.allMatches(value.toLowerCase())
.map((match) => match.group(0)!.replaceAll("'", ''));
const allowed = {
'i',
'im',
'am',
'my',
'name',
'is',
'what',
'your',
'nice',
'to',
'meet',
'you',
'how',
'do',
'spell',
'that',
'hello',
'hi',
'good',
'okay',
'tired',
'thanks',
'yes',
'no',
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'phone',
'number',
'it',
'a',
'book',
'pen',
'bag',
'key',
'where',
'from',
'this',
'mother',
'father',
'sister',
'brother',
'friend',
'day',
'today',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'time',
'oclock',
'like',
'water',
'coffee',
'tea',
'music',
'movies',
'please',
'say',
'again',
'speak',
'slowly',
'alex',
'mia',
'shen',
'hong',
'kong',
'beijing',
'shanghai',
};
return words.every(allowed.contains);
}
List<String>? _stringList(Object? value, {required int max}) {
if (value is! List || value.length > max) return null;
final result = <String>[];
for (final item in value) {
if (item is! String || item.trim().isEmpty || item.length > 120) {
return null;
}
result.add(item.trim());
}
return result.toSet().length == result.length ? result : null;
}
/// Validates the small feedback object before any AI wording reaches a
/// learner. The app treats an uncertain or malformed response as no feedback,
/// never as a language error.
WritingAiFeedback? decodeWritingAiFeedback(
String raw, {
required String expectedLessonId,
}) {
if (raw.length > 1800) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 6) return null;
if (data['schemaVersion'] != 'writing-feedback-1' ||
data['lessonId'] != expectedLessonId) {
return null;
}
final verdict = data['verdict'];
final feedback = data['feedback'];
final suggestion = data['suggestion'];
final missing = data['missing'];
if (verdict is! String ||
!const {'accepted', 'rewrite', 'uncertain'}.contains(verdict) ||
feedback is! String ||
feedback.trim().isEmpty ||
feedback.length > 240 ||
(suggestion != null &&
(suggestion is! String ||
suggestion.trim().isEmpty ||
suggestion.length > 180)) ||
missing is! List ||
missing.length > 3) {
return null;
}
final normalizedMissing = <String>[];
for (final item in missing) {
if (item is! String || item.trim().isEmpty || item.length > 80) {
return null;
}
normalizedMissing.add(item.trim());
}
return WritingAiFeedback(
verdict: verdict,
feedback: feedback.trim(),
suggestion: suggestion?.trim(),
missing: normalizedMissing,
);
} catch (_) {
return null;
}
}
+513
View File
@@ -0,0 +1,513 @@
import 'dart:io';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
/// Durable local storage for learner-owned state. Evidence, mastery, reviews,
/// sessions, assessments and caches are persisted as separate SQLite entities.
/// A legacy versioned snapshot may be read as a migration/recovery fallback,
/// but normal writes never create or replace one. No provider key, audio bytes,
/// or cloud data is stored here.
class LocalSnapshotStore {
LocalSnapshotStore._();
LocalSnapshotStore.forTesting(QueryExecutor executor) : _executor = executor;
static final instance = LocalSnapshotStore._();
QueryExecutor? _executor;
Future<QueryExecutor>? _opening;
Future<void>? _initializing;
Future<QueryExecutor> _open() => _opening ??= _create();
Future<QueryExecutor> _create() async {
final directory = await getApplicationSupportDirectory();
final database = NativeDatabase.createInBackground(
File('${directory.path}/kouyu_learning.sqlite'),
);
_executor = database;
await _ensureSchema(database);
return database;
}
Future<void> _ensureSchema(QueryExecutor executor) =>
_initializing ??= _openAndCreateSchema(executor);
Future<void> _openAndCreateSchema(QueryExecutor executor) async {
await executor.ensureOpen(_snapshotStoreUser);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learning_snapshots (
snapshot_id TEXT PRIMARY KEY NOT NULL,
schema_version INTEGER NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS attempt_evidence (
attempt_id TEXT PRIMARY KEY NOT NULL, item_id TEXT NOT NULL,
task_id TEXT NOT NULL, skill TEXT NOT NULL, input_mode TEXT NOT NULL,
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
transcript_edited INTEGER NOT NULL DEFAULT 0
)
''');
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'original_transcript',
definition: 'TEXT',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_confirmed',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_edited',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS mastery_items (
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
checkpoint INTEGER NOT NULL, needs_review INTEGER NOT NULL, first_taught_at TEXT,
payload TEXT NOT NULL DEFAULT '{}'
)
''');
await _ensureColumn(
executor,
table: 'mastery_items',
name: 'payload',
definition: "TEXT NOT NULL DEFAULT '{}'",
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS review_items (
item_id TEXT PRIMARY KEY NOT NULL, due_at TEXT NOT NULL, attempts INTEGER NOT NULL,
successful_reviews INTEGER NOT NULL, variant_index INTEGER NOT NULL, payload TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS study_sessions (
session_id TEXT PRIMARY KEY NOT NULL, session_type TEXT NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS temporary_lexicon_entries (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
definition TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learner_profiles (
profile_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS lesson_progress (
progress_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS assessment_records (
pack_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
completed_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS generated_content_cache (
cache_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
}
Future<void> _ensureColumn(
QueryExecutor executor, {
required String table,
required String name,
required String definition,
}) async {
final columns = await executor.runSelect(
'PRAGMA table_info($table)',
const [],
);
if (columns.any((column) => column['name'] == name)) return;
await executor.runCustom('ALTER TABLE $table ADD COLUMN $name $definition');
}
Future<String?> read() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final canonical = await _readCanonical(executor);
if (canonical != null) return jsonEncode(canonical);
final rows = await executor.runSelect(
'SELECT payload FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
return rows.isEmpty ? null : rows.single['payload'] as String?;
}
/// Rebuilds the application state from normalized local entities. The old
/// snapshot is intentionally only a migration/recovery fallback: once a
/// profile exists, a partially written or stale snapshot cannot win.
Future<Map<String, dynamic>?> _readCanonical(QueryExecutor executor) async {
final profileRows = await executor.runSelect(
'SELECT payload FROM learner_profiles WHERE profile_id = ?',
const ['current'],
);
final lessonRows = await executor.runSelect(
'SELECT payload FROM lesson_progress WHERE progress_id = ?',
const ['current'],
);
if (profileRows.isEmpty || lessonRows.isEmpty) return null;
try {
final result = <String, dynamic>{
..._decodeObject(profileRows.single['payload']),
..._decodeObject(lessonRows.single['payload']),
};
final contentRows = await executor.runSelect(
'SELECT payload FROM generated_content_cache WHERE cache_id = ?',
const ['adaptive'],
);
if (contentRows.isNotEmpty) {
result.addAll(_decodeObject(contentRows.single['payload']));
}
result['attemptEvidence'] =
(await executor.runSelect(
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
created_at, raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
FROM attempt_evidence ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'id': row['attempt_id'],
'itemId': row['item_id'],
'taskId': row['task_id'],
'skill': row['skill'],
'inputMode': row['input_mode'],
'outcome': row['outcome'],
'createdAt': row['created_at'],
'rawAnswer': row['raw_answer'],
'recordingPath': row['recording_path'],
'assisted': row['assisted'] == 1,
'variantIndex': row['variant_index'],
'originalTranscript': row['original_transcript'],
'transcriptConfirmed': row['transcript_confirmed'] == 1,
'transcriptEdited': row['transcript_edited'] == 1,
},
)
.toList();
result['mastery'] = (await executor.runSelect(
'SELECT payload FROM mastery_items ORDER BY item_id',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['reviews'] = (await executor.runSelect(
'SELECT payload FROM review_items ORDER BY due_at',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['temporaryLexicon'] =
(await executor.runSelect(
'''SELECT query, definition, provider, model, created_at
FROM temporary_lexicon_entries ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'definition': row['definition'],
'provider': row['provider'],
'model': row['model'],
'createdAt': row['created_at'],
},
)
.toList();
result['assessments'] = (await executor.runSelect(
'SELECT payload FROM assessment_records ORDER BY completed_at',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
final sessions = await executor.runSelect(
'SELECT session_type, payload FROM study_sessions',
const [],
);
for (final session in sessions) {
if (session['session_type'] == 'assessment') {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
} catch (_) {
// Keep a previous version usable if an interrupted legacy migration
// leaves any individual canonical payload unreadable.
return null;
}
}
Map<String, dynamic> _decodeObject(Object? raw) {
if (raw is! String) return const {};
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : const {};
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
// `learning_snapshots` belongs to the old storage format. Do not keep
// mirroring the complete in-memory state into it: canonical state is the
// normalized entity set below. Existing snapshots remain readable only
// until a successful entity restore makes them unnecessary.
await _writeEntities(transaction, payload);
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
Future<void> _writeEntities(QueryExecutor executor, String payload) async {
final data = jsonDecode(payload) as Map<String, dynamic>;
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await executor.runCustom('DELETE FROM $table');
}
for (final entry in (data['attemptEvidence'] as List? ?? const [])) {
if (entry is! Map) continue;
await executor.runCustom(
'''INSERT INTO attempt_evidence(
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
[
entry['id'],
entry['itemId'],
entry['taskId'],
entry['skill'],
entry['inputMode'],
entry['outcome'],
entry['createdAt'],
entry['rawAnswer'],
entry['recordingPath'],
entry['assisted'] == true ? 1 : 0,
entry['variantIndex'] ?? 0,
entry['originalTranscript'],
entry['transcriptConfirmed'] == true ? 1 : 0,
entry['transcriptEdited'] == true ? 1 : 0,
],
);
}
for (final item in (data['mastery'] as List? ?? const [])) {
if (item is! Map) continue;
await executor.runCustom(
'''INSERT INTO mastery_items(
item_id, label, status, checkpoint, needs_review, first_taught_at, payload
) VALUES (?, ?, ?, ?, ?, ?, ?)''',
[
item['id'],
item['label'],
item['status'],
item['checkpoint'] ?? 0,
item['needsReview'] == true ? 1 : 0,
item['firstTaughtAt'],
jsonEncode(item),
],
);
}
for (final item in (data['reviews'] as List? ?? const [])) {
if (item is! Map) continue;
await executor
.runCustom('INSERT INTO review_items VALUES (?, ?, ?, ?, ?, ?)', [
item['id'],
item['dueAt'],
item['attempts'] ?? 0,
item['successfulReviews'] ?? 0,
item['variantIndex'] ?? 0,
jsonEncode(item),
]);
}
final sessions = {
'lesson': {
'step': data['lessonStep'],
'lessonId': data['activeLessonId'],
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
await executor
.runCustom('INSERT INTO study_sessions VALUES (?, ?, ?, ?)', [
entry.key,
entry.key,
jsonEncode(entry.value),
DateTime.now().toUtc().toIso8601String(),
]);
}
final now = DateTime.now().toUtc().toIso8601String();
await executor.runCustom('INSERT INTO learner_profiles VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'onboardingComplete',
'goal',
'placement',
'dailyMinutes',
'showChineseHints',
'keepRecordings',
'aiEndpoint',
'aiModel',
'aiProvider',
'reportedAiVariantKeys',
'reportedAdaptiveLessonIds',
])
key: data[key],
}),
now,
]);
await executor.runCustom('INSERT INTO lesson_progress VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'lessonStep',
'previewIndex',
'completedLessons',
'activeLessonId',
'completedLessonIds',
'completedSegmentIds',
'activeSegmentIndexes',
'lessonListeningComplete',
'lessonSpeakingComplete',
'lessonReadingComplete',
'lessonWritingComplete',
'lessonDialogueComplete',
'independentAttemptComplete',
'independentAttemptAssisted',
'independentAttemptSpoken',
'lessonWritingDraft',
'independentAttemptDraft',
])
key: data[key],
}),
now,
]);
for (final assessment in (data['assessments'] as List? ?? const [])) {
if (assessment is! Map || assessment['packId'] is! String) continue;
await executor
.runCustom('INSERT INTO assessment_records VALUES (?, ?, ?)', [
assessment['packId'],
jsonEncode(assessment),
assessment['completedAt'] ?? now,
]);
}
await executor.runCustom(
'INSERT INTO generated_content_cache VALUES (?, ?, ?)',
[
'adaptive',
jsonEncode({
for (final key in const [
'cachedAdaptiveLessonRaw',
'cachedAdaptiveLessonAuditedAt',
'cachedAdaptiveLessonAuditor',
'adaptiveLessonDraftId',
'adaptiveLessonDraftIndex',
'adaptiveLessonDraftAnswer',
'adaptiveLessonDraftReferenceShown',
'adaptiveLessonDraftUsedVoice',
'adaptiveLessonDraftTranscriptEdited',
'adaptiveLessonDraftTranscriptConfirmed',
'adaptiveLessonDraftOriginalTranscript',
'adaptiveLessonDraftRecordingPath',
])
key: data[key],
}),
now,
],
);
for (final entry in (data['temporaryLexicon'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final definition = entry['definition'] as String? ?? '';
if (key.isEmpty || definition.trim().isEmpty) continue;
await executor.runCustom(
'INSERT INTO temporary_lexicon_entries VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
definition,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
await transaction.runCustom(
'DELETE FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await transaction.runCustom('DELETE FROM $table');
}
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
}
final _snapshotStoreUser = _SnapshotStoreUser();
class _SnapshotStoreUser implements QueryExecutorUser {
@override
int get schemaVersion => 1;
@override
Future<void> beforeOpen(
QueryExecutor executor,
OpeningDetails details,
) async {}
}
+318
View File
@@ -0,0 +1,318 @@
enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { home, learn, dialogue, review, progress }
enum LessonStep {
preview,
listening,
speaking,
reading,
writing,
dialogue,
independent,
complete,
}
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
enum EvidenceKind {
exposure,
assisted,
independentSuccess,
languageError,
pending,
}
class AttemptEvidence {
const AttemptEvidence({
required this.id,
required this.itemId,
required this.taskId,
required this.skill,
required this.inputMode,
required this.outcome,
required this.createdAt,
this.rawAnswer,
this.recordingPath,
this.assisted = false,
this.variantIndex = 0,
this.originalTranscript,
this.transcriptConfirmed = false,
this.transcriptEdited = false,
});
final String id;
final String itemId;
final String taskId;
final String skill;
final String inputMode;
final EvidenceKind outcome;
final DateTime createdAt;
final String? rawAnswer;
/// Local-only file reference. It is absent unless the learner opted to keep
/// recordings; no path or audio is sent to an AI provider.
final String? recordingPath;
final bool assisted;
final int variantIndex;
/// The device STT result before the learner edits it. This local-only field
/// distinguishes confirmed spoken input from a subsequent typed rewrite.
final String? originalTranscript;
final bool transcriptConfirmed;
final bool transcriptEdited;
}
enum AiProviderType { mock, gemini, openAi, compatible }
enum AssessmentSkill { listening, speaking, reading, writing }
enum ContentStatus { draft, validated, approved, rejected }
enum ContentSource { builtInOriginal, aiGenerated, importedReference }
/// A validated, bounded suggestion from an AI conversation provider.
/// The client still owns task completion and mastery decisions.
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.feedback,
});
final String reply;
final Map<String, String> slots;
final List<String> evidence;
final bool suggestsComplete;
final String? feedback;
}
/// A bounded suggestion only. It never changes completion, mastery, or a
/// stage result; those decisions remain on-device and evidence-based.
class WritingAiFeedback {
const WritingAiFeedback({
required this.verdict,
required this.feedback,
required this.suggestion,
required this.missing,
});
/// `accepted`, `rewrite`, or `uncertain`.
final String verdict;
final String feedback;
final String? suggestion;
final List<String> missing;
}
class AssessmentRecord {
const AssessmentRecord({
required this.packId,
required this.completedAt,
required this.results,
this.pendingSkills = const {},
});
final String packId;
final DateTime completedAt;
final Map<AssessmentSkill, bool> results;
final Set<AssessmentSkill> pendingSkills;
bool get passed =>
AssessmentSkill.values.every((skill) => results[skill] == true);
List<AssessmentSkill> get failedSkills => AssessmentSkill.values
.where(
(skill) => results[skill] != true && !pendingSkills.contains(skill),
)
.toList();
}
class AssessmentDraft {
const AssessmentDraft({
required this.packId,
required this.taskIndex,
required this.results,
});
final String packId;
final int taskIndex;
final Map<String, bool> results;
}
class DialogueDraft {
const DialogueDraft({
required this.lessonId,
required this.stage,
required this.turns,
required this.usedHelp,
});
final String lessonId;
final int stage;
final List<DialogueTurn> turns;
final bool usedHelp;
}
class LessonSummary {
const LessonSummary({
required this.number,
required this.title,
required this.outcome,
this.isComplete = false,
this.isCurrent = false,
});
final int number;
final String title;
final String outcome;
final bool isComplete;
final bool isCurrent;
}
class VocabularyItem {
const VocabularyItem({
required this.id,
required this.word,
required this.meaning,
required this.example,
required this.exampleMeaning,
this.ipa,
});
final String id;
final String word;
final String meaning;
final String example;
final String exampleMeaning;
final String? ipa;
}
/// A non-core explanation returned by a configured text provider for a word
/// or phrase that is not present in the reviewed course lexicon. These
/// entries deliberately never participate in review scheduling or mastery.
class TemporaryLexiconEntry {
const TemporaryLexiconEntry({
required this.query,
required this.definition,
required this.provider,
required this.model,
required this.createdAt,
});
final String query;
final String definition;
final String provider;
final String model;
final DateTime createdAt;
}
class ReviewItem {
const ReviewItem({
required this.id,
required this.target,
required this.prompt,
required this.hint,
required this.dueAt,
required this.skill,
this.attempts = 0,
this.successfulReviews = 0,
this.variantIndex = 0,
this.lastProgressedAt,
this.isAiGenerated = false,
});
final String id;
final String target;
final String prompt;
final String hint;
final DateTime dueAt;
final String skill;
final int attempts;
final int successfulReviews;
final int variantIndex;
final DateTime? lastProgressedAt;
final bool isAiGenerated;
ReviewItem copyWith({
DateTime? dueAt,
int? attempts,
int? successfulReviews,
int? variantIndex,
String? prompt,
String? hint,
String? skill,
DateTime? lastProgressedAt,
bool? isAiGenerated,
}) => ReviewItem(
id: id,
target: target,
prompt: prompt ?? this.prompt,
hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill,
attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex,
lastProgressedAt: lastProgressedAt ?? this.lastProgressedAt,
isAiGenerated: isAiGenerated ?? this.isAiGenerated,
);
}
class MasteryItem {
const MasteryItem({
required this.id,
required this.label,
required this.status,
required this.evidence,
this.needsReview = false,
this.checkpoint = 0,
this.firstTaughtAt,
});
final String id;
final String label;
final MasteryStatus status;
final List<EvidenceKind> evidence;
final bool needsReview;
final int checkpoint;
final DateTime? firstTaughtAt;
MasteryItem copyWith({
MasteryStatus? status,
List<EvidenceKind>? evidence,
bool? needsReview,
int? checkpoint,
DateTime? firstTaughtAt,
}) => MasteryItem(
id: id,
label: label,
status: status ?? this.status,
evidence: evidence ?? this.evidence,
needsReview: needsReview ?? this.needsReview,
checkpoint: checkpoint ?? this.checkpoint,
firstTaughtAt: firstTaughtAt ?? this.firstTaughtAt,
);
}
class DialogueTurn {
const DialogueTurn({required this.text, required this.isLearner});
final String text;
final bool isLearner;
}
/// Ephemeral presentation data for a completed controlled dialogue. The
/// related recap review is separately persisted in AppState.
class DialogueSummaryData {
const DialogueSummaryData({
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
}
@@ -0,0 +1,93 @@
import 'models.dart';
class ReviewCheckResult {
const ReviewCheckResult({required this.complete, required this.message});
final bool complete;
final String message;
}
/// Checks only the minimum information a learner needs to produce in an A0
/// review. It intentionally accepts common contractions and variable slot
/// values instead of comparing an answer with one fixed sentence.
class ReviewFeedback {
const ReviewFeedback._();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(token);
bool phrase(String value) => text.contains(value);
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final complete = switch (item.id) {
'A0-P01' => introduction && tokens.length >= 2,
'A0-P02' => (phrase("what's your name") || phrase('what is your name')),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") ||
phrase('what is your phone number'),
'A0-P08' =>
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') => tokens.contains(
item.target.toLowerCase(),
),
_ => tokens.length >= 2,
};
return ReviewCheckResult(
complete: complete,
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
);
}
static String _hint(String id) => switch (id) {
'A0-P01' => '用 Im / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:Whats your name?',
'A0-P04' => '问对方怎样拼写:How do you spell …?',
'A0-P08' => '说出至少三个英文数字,也可用 My number is … 开头。',
'A0-P10' => '用 It’s / It is 加上一个物品。',
'A0-P12' => '用 I’m from … 说出一个地点。',
'A0-P17' => '用 It’s … o’clock 说整点时间。',
'A0-P18' => '用 I like … 说一项喜好。',
_ => '再补充本次目标中的关键英文词或句型。',
};
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:speech_to_text/speech_to_text.dart';
/// Device speech facilities are optional. Callers must keep a text fallback
/// because availability depends on device language packs and permissions.
class VoiceService {
VoiceService._();
static final instance = VoiceService._();
final FlutterTts _tts = FlutterTts();
final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
bool _speechReady = false;
Future<void> speak(String text, {bool slow = false}) async {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
}
Future<void> stopSpeaking() => _tts.stop();
Future<bool> startRecording() async {
if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) await recordings.create(recursive: true);
final timestamp = DateTime.now().microsecondsSinceEpoch;
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: '${recordings.path}/practice_$timestamp.m4a',
);
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<void> playRecording(String path) async {
await _player.stop();
await _player.play(DeviceFileSource(path));
}
Future<void> stopRecordingPlayback() => _player.stop();
Future<void> deleteRecording(String? path) async {
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) await file.delete();
}
Future<int> deleteAllRecordings() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return 0;
final files = await recordings
.list()
.where((item) => item is File)
.toList();
for (final file in files.cast<File>()) {
await file.delete();
}
return files.length;
}
Future<List<String>> listRecordingPaths() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return const [];
final files = await recordings
.list()
.where((item) => item is File && item.path.endsWith('.m4a'))
.cast<File>()
.toList();
files.sort((left, right) => right.path.compareTo(left.path));
return files.map((file) => file.path).toList();
}
Future<void> disposeRecording() async {
await _recorder.dispose();
await _player.dispose();
}
Future<bool> initializeSpeech() async {
_speechReady = await _stt.initialize();
return _speechReady;
}
Future<bool> startListening(
void Function(String text, bool finalResult) onResult,
) async {
if (!_speechReady && !await initializeSpeech()) {
return false;
}
await _stt.listen(
onResult: (result) =>
onResult(result.recognizedWords, result.finalResult),
listenOptions: SpeechListenOptions(
localeId: 'en_US',
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 4),
),
);
return true;
}
Future<void> stopListening() => _stt.stop();
bool get isListening => _stt.isListening;
}
@@ -0,0 +1,112 @@
class WritingCheckResult {
const WritingCheckResult({required this.complete, required this.message});
final bool complete;
final String message;
}
/// A deliberately small offline checker for teaching tasks. It checks only
/// the essential A0 information, accepts variable names/places, and never
/// claims to grade pronunciation or nuanced grammar.
class WritingFeedback {
const WritingFeedback._();
static WritingCheckResult check(
String lessonId,
String input, {
String? segmentId,
}) {
final text = input.toLowerCase().replaceAll('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool hasPhrase(String phrase) => text.contains(phrase);
bool hasAny(Iterable<String> choices) => choices.any(has);
final hasIntroduction =
hasPhrase("i'm") || hasPhrase('i am') || hasPhrase('my name is');
final hasItIs = hasPhrase("it's") || hasPhrase('it is');
final complete = switch (segmentId) {
'a0-04-a' => hasAll(words, ['zero', 'one', 'two', 'three']),
'a0-04-b' => hasAll(words, ['six', 'seven', 'eight']),
'a0-04-c' =>
RegExp(
r'\b(one|two|three|four|five|six|seven|eight|nine|zero)\b',
).allMatches(text).length >=
3,
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
'a0-08-b' =>
hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']),
'a0-08-c' => hasItIs && (has('oclock') || text.contains("o'clock")),
_ => switch (lessonId) {
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
'a0-02' =>
(hasPhrase('my name') || hasIntroduction) &&
RegExp(r'[a-z](?:[ -]?[a-z]){2,}').hasMatch(text),
'a0-03' => hasIntroduction && hasAny(['good', 'okay', 'tired']),
'a0-04' =>
RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length >=
3,
'a0-05' => hasItIs && hasAny(['book', 'pen', 'bag', 'key']),
'a0-06' => hasIntroduction && has('from') && words.length >= 3,
'a0-07' =>
hasPhrase('this is my') &&
hasAny(['mother', 'father', 'sister', 'brother', 'friend']),
'a0-08' =>
hasItIs &&
(hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]) ||
has('clock')),
'a0-09' => hasIntroduction && has('like') && words.length >= 3,
'a0-10' => hasIntroduction && has('from') && has('like'),
_ => words.length >= 2,
},
};
if (complete) {
return const WritingCheckResult(
complete: true,
message: '这句已经表达完整。继续在对话里用一次吧。',
);
}
return WritingCheckResult(
complete: false,
message: _hintFor(segmentId ?? lessonId),
);
}
static bool hasAll(Set<String> words, Iterable<String> required) =>
required.every(words.contains);
static String _hintFor(String lessonId) => switch (lessonId) {
'a0-04-a' => '请写出 zero、one、two、three 四个数字。',
'a0-04-b' => '请写出 six、seven、eight 三个数字。',
'a0-04-c' => '用英文一个一个写出三个数字,例如 one-three-nine。',
'a0-08-a' => '用 It is / Its 加 Monday、Tuesday 或 Wednesday。',
'a0-08-b' => '用 It is / Its 加 Thursday、Friday、Saturday 或 Sunday。',
'a0-08-c' => '用 It is / Its 加数字和 oclock,例如 Its three oclock。',
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
'a0-02' => '写出姓名,并把至少三个字母用空格或连字符拼出来。',
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',
'a0-06' => '用 I’m from … 写成一个完整句子。',
'a0-07' => '用 This is my … 介绍一位家人或朋友。',
'a0-08' => '用 It’s … 写一个星期或整点时间。',
'a0-09' => '用 I like … 写出一种喜好。',
'a0-10' => '分别写姓名、来自哪里和一项喜好三部分。',
_ => '再补充一个完整英文句子。',
};
}
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/assessment_bank.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class AssessmentPreparationPage extends StatefulWidget {
const AssessmentPreparationPage({
super.key,
required this.state,
required this.pack,
required this.onStart,
required this.onBack,
});
final AppState state;
final AssessmentPack pack;
final VoidCallback onStart;
final VoidCallback onBack;
@override
State<AssessmentPreparationPage> createState() =>
_AssessmentPreparationPageState();
}
class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
bool? microphoneReady;
bool checkingMicrophone = false;
Future<void> _checkMicrophone() async {
setState(() => checkingMicrophone = true);
final ready = await VoiceService.instance.initializeSpeech();
if (!mounted) return;
setState(() {
microphoneReady = ready;
checkingMicrophone = false;
});
}
@override
Widget build(BuildContext context) {
final draft = widget.state.assessmentDraft;
final canResume = draft != null && draft.packId == widget.pack.id;
final pack = widget.pack;
return AppPage(
appBar: AppBar(title: const Text('评估准备')),
child: SpacedColumn(
children: [
Eyebrow('A0 阶段评估 · ${pack.id}'),
Text('先确认评估方式', style: Theme.of(context).textTheme.headlineMedium),
const Text('这不是日常练习:它用于确认你能在没有提示时完成基础交流。'),
SectionCard(
tint: AppColors.softGreen,
child: Text(
'本题组包含:听力 ${pack.forSkill(AssessmentSkill.listening).length} 题、'
'阅读 ${pack.forSkill(AssessmentSkill.reading).length} 题、'
'写作 ${pack.forSkill(AssessmentSkill.writing).length} 题、'
'口语 ${pack.forSkill(AssessmentSkill.speaking).length} 题。',
),
),
SectionCard(
child: Text(
'当前核心项:${widget.state.coreUsableCount} 项可用,'
'${widget.state.coreMasteredCount} 项已掌握。\n'
'这些数字帮助你判断准备程度,但不会替代本次评估。',
),
),
const SectionCard(
tint: AppColors.warm,
child: Text(
'评估规则\n'
'• 不提供翻译、查词、句框或答案。\n'
'• 听力题必须先播放音频。\n'
'• 口语题必须用麦克风回答,且不可编辑转写。\n'
'• 麦克风不可用时可保留口语待评估;不会算作语言错误。',
),
),
SecondaryButton(
label: checkingMicrophone
? '正在检查麦克风…'
: microphoneReady == true
? '麦克风可用'
: microphoneReady == false
? '麦克风暂不可用,重新检查'
: '检查麦克风',
onPressed: checkingMicrophone ? null : _checkMicrophone,
),
if (canResume)
SectionCard(
tint: AppColors.warm,
child: Text('会从上次中断的第 ${draft.taskIndex + 1} 题继续,已完成答案会保留。'),
),
PrimaryButton(
label: canResume ? '继续评估' : '开始评估',
onPressed: widget.onStart,
),
SecondaryButton(label: '返回阶段进度', onPressed: widget.onBack),
],
),
);
}
}
class AssessmentPage extends StatefulWidget {
const AssessmentPage({
super.key,
required this.state,
required this.pack,
required this.onFinished,
required this.onStartReplacement,
});
final AppState state;
final AssessmentPack pack;
final VoidCallback onFinished;
final ValueChanged<AssessmentPack> onStartReplacement;
@override
State<AssessmentPage> createState() => _AssessmentPageState();
}
class _AssessmentPageState extends State<AssessmentPage> {
final controller = TextEditingController();
final Map<String, bool> results = {};
int index = 0;
bool usedMic = false;
bool transcriptEdited = false;
String lastTranscript = '';
bool listening = false;
bool audioPlayed = false;
bool speakingUnavailable = false;
AssessmentRecord? completedRecord;
AssessmentTask get task => widget.pack.tasks[index];
@override
void initState() {
super.initState();
final draft = widget.state.assessmentDraft;
if (draft != null &&
draft.packId == widget.pack.id &&
draft.taskIndex < widget.pack.tasks.length) {
index = draft.taskIndex;
results.addAll(draft.results);
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
Future<void> _play() async {
await VoiceService.instance.speak(task.audio!);
if (mounted) setState(() => audioPlayed = true);
}
Future<void> _mic() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (mounted) {
setState(() {
controller.text = text;
usedMic = true;
lastTranscript = text;
});
}
});
if (mounted) {
setState(() {
listening = ready;
speakingUnavailable = !ready;
});
}
if (!ready && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
);
}
}
bool _openCorrect() {
return checkOpenAssessmentAnswer(task, controller.text);
}
void _submit([int? answer]) {
if (answer == null &&
task.skill == AssessmentSkill.speaking &&
(!usedMic || transcriptEdited)) {
setState(() => speakingUnavailable = true);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请使用未编辑的语音转写,或将口语保留为待评估。')));
return;
}
final correct = answer != null
? answer == task.answerIndex &&
(task.skill != AssessmentSkill.listening || audioPlayed)
: _openCorrect() &&
(task.skill != AssessmentSkill.speaking ||
(usedMic && !transcriptEdited));
results[task.id] = correct;
widget.state.recordAssessmentAttempt(
taskId: task.id,
skill: _skillLabel(task.skill),
correct: correct,
rawAnswer: answer == null ? controller.text.trim() : task.choices[answer],
spoken: task.skill == AssessmentSkill.speaking,
);
if (index + 1 < widget.pack.tasks.length) {
setState(() {
index++;
controller.clear();
usedMic = false;
transcriptEdited = false;
lastTranscript = '';
listening = false;
audioPlayed = false;
});
widget.state.saveAssessmentDraft(
AssessmentDraft(
packId: widget.pack.id,
taskIndex: index,
results: results,
),
);
} else {
_finish();
}
}
void _markSpeakingPending() {
for (final speaking in widget.pack.forSkill(AssessmentSkill.speaking)) {
if (!results.containsKey(speaking.id)) {
results[speaking.id] = false;
widget.state.recordAssessmentPending(
taskId: speaking.id,
skill: _skillLabel(AssessmentSkill.speaking),
reason: '设备麦克风或语音识别不可用,等待补测。',
);
}
}
_finish(pendingSkills: const {AssessmentSkill.speaking});
}
void _finish({Set<AssessmentSkill> pendingSkills = const {}}) {
bool passed(AssessmentSkill skill) {
final items = widget.pack.forSkill(skill);
final score = items.where((item) => results[item.id] == true).length;
if (skill == AssessmentSkill.listening) {
return score >= 8 &&
(results[items[3].id] == true || results[items[4].id] == true) &&
results[items[8].id] == true;
}
if (skill == AssessmentSkill.reading) {
return score >= 4 && results[items[1].id] == true;
}
if (skill == AssessmentSkill.writing) {
return score >= 4 &&
items.take(3).every((item) => results[item.id] == true);
}
return score == items.length;
}
final record = AssessmentRecord(
packId: widget.pack.id,
completedAt: DateTime.now(),
results: {
for (final skill in AssessmentSkill.values) skill: passed(skill),
},
pendingSkills: pendingSkills,
);
final merged = widget.state.recordAssessment(record);
widget.state.clearAssessmentDraft();
setState(() => completedRecord = merged);
}
@override
Widget build(BuildContext context) {
final record = completedRecord;
if (record != null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('评估结果已保存'),
Text(
record.passed
? '本题组四技能通过。'
: record.pendingSkills.isNotEmpty
? '已保存完成的技能;有技能待评估。'
: '先补练,再使用替换题补测。',
style: Theme.of(context).textTheme.headlineMedium,
),
if (record.passed)
const SectionCard(
child: Text('已通过的技能在本题组 7 天有效窗口内保留。第二题组仍须使用不同题面。'),
),
if (!record.passed) ...[
if (record.pendingSkills.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: Text(
'待评估:${record.pendingSkills.map(_skillLabel).join('')}。技术问题不会计为语言错误。',
),
),
if (record.failedSkills.isNotEmpty) const Text('建议补练:'),
for (final skill in record.failedSkills)
SectionCard(child: Text(_remediation(skill))),
],
if (!record.passed && replacementFor(widget.pack.id) != null)
SecondaryButton(
label: '开始替换题补测',
onPressed: () =>
widget.onStartReplacement(replacementFor(widget.pack.id)!),
),
PrimaryButton(label: '回到阶段进度', onPressed: widget.onFinished),
],
),
);
}
return AppPage(
appBar: AppBar(
title: Text(
'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}',
),
),
child: SpacedColumn(
children: [
Text(
_skillLabel(task.skill),
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('评估中不提供翻译、句框或答案。需要帮助请退出后先做补练。'),
if (task.skill == AssessmentSkill.listening) ...[
PrimaryButton(
label: audioPlayed ? '再播放一次' : '播放音频',
onPressed: _play,
),
const Text('请根据听到的内容选择答案。'),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: audioPlayed ? () => _submit(i) : null,
child: Text(task.choices[i]),
),
] else if (task.skill == AssessmentSkill.reading) ...[
SectionCard(
child: Text(
task.prompt,
style: const TextStyle(fontSize: 17, height: 1.6),
),
),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: () => _submit(i),
child: Text(task.choices[i]),
),
] else ...[
SectionCard(
child: Text(task.prompt, style: const TextStyle(fontSize: 18)),
),
TextField(
controller: controller,
onChanged: (value) => setState(() {
if (task.skill == AssessmentSkill.speaking &&
usedMic &&
value != lastTranscript) {
transcriptEdited = true;
}
}),
minLines: 2,
decoration: InputDecoration(
hintText: task.skill == AssessmentSkill.speaking
? '使用麦克风说出答案;文字仅作待评估记录'
: '输入英文答案',
border: const OutlineInputBorder(),
),
),
if (task.skill == AssessmentSkill.speaking)
SecondaryButton(
label: listening ? '停止录音' : '使用麦克风回答',
onPressed: _mic,
),
if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
SecondaryButton(
label: '将口语保留为待评估',
onPressed: _markSpeakingPending,
),
PrimaryButton(
label: '提交',
onPressed: controller.text.trim().isEmpty ? null : _submit,
),
],
],
),
);
}
String _remediation(AssessmentSkill skill) => switch (skill) {
AssessmentSkill.listening => '听力:回到数字、星期/时间和场景听辨复习;下一次会使用不同音频。',
AssessmentSkill.speaking => '口语:练姓名、号码、物品、家人、星期、整点和请求重复;确认未编辑转写后再补测。',
AssessmentSkill.reading => '阅读:复习人物、地点、数字和时间信息定位,再阅读不同短对话。',
AssessmentSkill.writing => '写作:分别练完整的姓名、地点、喜好、物品、星期/时间句,不使用句框。',
};
String _skillLabel(AssessmentSkill skill) => switch (skill) {
AssessmentSkill.listening => '听力',
AssessmentSkill.speaking => '口语',
AssessmentSkill.reading => '阅读',
AssessmentSkill.writing => '写作',
};
}
@@ -0,0 +1,680 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart});
final VoidCallback onStart;
@override
Widget build(BuildContext context) => AppPage(
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答轮,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'初次见面',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text('A0', style: TextStyle(color: AppColors.green)),
],
),
Text(
'介绍姓名、地点、状态或喜好,并反问对方',
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(label: '开始对话', onPressed: onStart),
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
],
),
);
}
class _LockedScene extends StatelessWidget {
const _LockedScene({required this.title, required this.note});
final String title;
final String note;
@override
Widget build(BuildContext context) => SectionCard(
child: Row(
children: [
const Icon(Icons.lock_outline, color: AppColors.muted),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class DialoguePage extends StatefulWidget {
const DialoguePage({
super.key,
required this.state,
required this.onFinished,
this.isLessonDialogue = false,
});
final AppState state;
final ValueChanged<DialogueSummaryData?> onFinished;
final bool isLessonDialogue;
@override
State<DialoguePage> createState() => _DialoguePageState();
}
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
String? hint;
bool listening = false;
bool recording = false;
bool playingRecording = false;
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? recordingPath;
bool waitingForReply = false;
String? validationError;
LessonDialogue get script => widget.isLessonDialogue
? dialogueBySegmentId(
lessonById(widget.state.activeLessonId)
.segments[widget.state.activeSegmentIndexFor(
widget.state.activeLessonId,
)]
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
);
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
stage = draft.stage;
usedHelp = draft.usedHelp;
turns.addAll(draft.turns);
} else {
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
}
}
@override
void dispose() {
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose();
super.dispose();
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
return;
}
widget.state.recordDialogueAttempt(
taskId:
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
rawAnswer: text,
assisted: usedHelp,
spoken: usedVoice && !transcriptEdited,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
recordingPath = null;
}
final nextStage = stage + 1;
setState(() {
turns.add(DialogueTurn(text: text, isLearner: true));
controller.clear();
stage = nextStage;
waitingForReply = true;
hint = null;
validationError = null;
});
_saveDraft();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
history: turns
.map(
(turn) => <String, String>{
'role': turn.isLearner ? 'user' : 'assistant',
'content': turn.text,
},
)
.toList(),
);
if (!mounted) return;
setState(() {
turns.add(
DialogueTurn(
text:
aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!'),
isLearner: false,
),
);
waitingForReply = false;
});
_saveDraft();
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
);
}
bool _matchesCurrentTask(String response) {
if (!widget.isLessonDialogue) {
final text = response.toLowerCase();
return switch (stage) {
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
2 => RegExp(
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
).hasMatch(text),
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
};
}
final text = response.toLowerCase();
final lessonId = widget.state.activeLessonId;
if (lessonById(lessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
}
if (stage == 0) {
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
}
if (lessonId == 'a0-02' && stage == 1) {
return RegExp(
r'[a-z](?:[ -]?[a-z]){2,}',
caseSensitive: false,
).hasMatch(text);
}
final expected = switch (lessonId) {
'a0-01' => ['nice', 'hello', 'what'],
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
'a0-04' => [
'yes',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'zero',
'what',
],
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
'a0-06' => ['from', 'where', 'bye'],
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
'a0-08' => [
'it',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'clock',
'what',
],
'a0-09' => ['like', 'yes', 'no', 'do'],
'a0-10' => ['please', 'what', 'like'],
_ => ['nice', 'how', 'what', 'from', 'like'],
};
return expected.any(text.contains);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
.segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)]
.id;
void _finish() {
if (widget.isLessonDialogue) {
widget.state.clearDialogueDraft();
widget.state.completeLessonDialogue();
widget.onFinished(null);
return;
}
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
: learnerTurns.first.text;
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
personalSentence: personalSentence,
usedHelp: usedHelp,
),
);
}
Future<void> _playLatestAi({required bool slow}) async {
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
if (latest == null) return;
await VoiceService.instance.speak(latest.text, slow: slow);
if (!slow || !mounted) return;
setState(() => usedHelp = true);
_saveDraft();
}
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
});
if (!mounted) return;
setState(() => listening = available);
if (!available) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
void _showWord() => showLexiconLookup(
context,
state: widget.state,
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
);
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
return AppPage(
appBar: AppBar(
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
),
),
child: SpacedColumn(
children: [
Eyebrow('目标:${script.goal}'),
Container(
constraints: const BoxConstraints(minHeight: 250),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: turns.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final turn = turns[index];
return Align(
alignment: turn.isLearner
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(maxWidth: 290),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: turn.isLearner
? AppColors.warm
: AppColors.softGreen,
borderRadius: BorderRadius.circular(15),
),
child: LexiconText(turn.text, state: widget.state),
),
);
},
),
),
if (!finished) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_AssistChip(
label: '提示',
onTap: () {
setState(() {
usedHelp = true;
hint = script.hints[stage];
});
_saveDraft();
},
),
_AssistChip(
label: '翻译',
onTap: () {
setState(() {
usedHelp = true;
hint = translations[stage];
});
_saveDraft();
},
),
_AssistChip(
label: '慢一点',
onTap: () => _playLatestAi(slow: true),
),
_AssistChip(
label: '重说',
onTap: () => _playLatestAi(slow: false),
),
_AssistChip(label: '查词', onTap: _showWord),
],
),
if (hint != null)
SectionCard(
tint: AppColors.warm,
child: Text(
hint!,
style: const TextStyle(color: AppColors.warmInk),
),
),
if (validationError != null)
Text(
validationError!,
style: const TextStyle(color: AppColors.warmInk),
),
TextField(
controller: controller,
onChanged: (value) => setState(() {
if (usedVoice && value != lastTranscript) {
transcriptEdited = true;
}
}),
onSubmitted: (_) => send(),
decoration: InputDecoration(
hintText: '输入你的英文回答',
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止录音' : '语音输入',
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
),
onPressed: _toggleListening,
),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: waitingForReply ? null : send,
),
border: const OutlineInputBorder(),
),
),
if (usedVoice)
Text(
transcriptEdited
? '你修改了设备转写:这轮按文字作答保存。'
: '这是设备转写;未修改提交后会作为语音尝试保存。',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
Text(
widget.state.keepRecordings
? '录音只保存在本机,不会发送给 AI。'
: '录音仅供本次回听,离开后自动删除。',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const Text(
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
if (waitingForReply) const LinearProgressIndicator(),
] else ...[
SectionCard(
tint: usedHelp ? AppColors.warm : AppColors.softGreen,
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
),
),
PrimaryButton(
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
onPressed: _finish,
),
],
],
),
);
}
}
class _AssistChip extends StatelessWidget {
const _AssistChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => ActionChip(
label: Text(label),
backgroundColor: AppColors.surface,
side: const BorderSide(color: AppColors.line),
onPressed: onTap,
);
}
class DialogueSummaryPage extends StatelessWidget {
const DialogueSummaryPage({
super.key,
required this.summary,
required this.onHome,
required this.onLesson,
required this.onRetry,
});
final DialogueSummaryData summary;
final VoidCallback onHome;
final VoidCallback onLesson;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) => AppPage(
child: SpacedColumn(
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
title: '你的个人复习卡(明天出现)',
sentence: summary.personalSentence,
tint: AppColors.green,
),
),
Text(
summary.usedHelp
? '本次使用过提示。下次可以先不看提示,再试一次。'
: '下次试着换一个名字、地点或喜好,再完成同一任务。',
),
SecondaryButton(label: '再练一次初次见面', onPressed: onRetry),
PrimaryButton(label: '开始一节课程', onPressed: onLesson),
SecondaryButton(label: '回到首页', onPressed: onHome),
],
),
);
}
class _SummaryLine extends StatelessWidget {
const _SummaryLine({
required this.icon,
required this.title,
required this.sentence,
required this.tint,
});
final IconData icon;
final String title;
final String sentence;
final Color tint;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: tint),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: tint, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(sentence),
],
),
),
],
);
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/seed_courses.dart';
import '../../widgets/app_widgets.dart';
class HomePage extends StatelessWidget {
const HomePage({
super.key,
required this.state,
required this.onStartPrimaryTask,
required this.onOpenDialogue,
required this.onResumeLessonDialogue,
});
final AppState state;
final VoidCallback onStartPrimaryTask;
final VoidCallback onOpenDialogue;
final VoidCallback onResumeLessonDialogue;
@override
Widget build(BuildContext context) {
final isReview = state.reviewIsPrimary;
final count = state.dueReviewCount;
final activeLesson = lessonById(state.activeLessonId);
final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1;
final resumeDialogue = state.hasResumableLessonDialogue;
final primaryTitle = resumeDialogue
? '继续第 ${activeLesson.number} 课的课程对话'
: isReview
? '先复习 $count'
: '${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length}' : ''}';
final primaryNote = resumeDialogue
? '已保留你的对话进度和提示状态。'
: isReview
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
: '预热词汇 → 听说读写 → 对话 → 独立尝试';
return AppPage(
child: SpacedColumn(
spacing: 16,
children: [
const Eyebrow('今天的学习'),
Text(
'今天,说几句能用的英语。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
isReview ? '到期复习优先;完成后再进入新内容。' : '没有到期复习,继续完成一个小任务。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
child: Row(
children: [
const Icon(Icons.psychology_outlined, color: AppColors.green),
const SizedBox(width: 10),
Expanded(
child: Text(
'已接触 ${state.knownItemCount} 项 · 可独立使用 ${state.usableMasteryCount}',
),
),
],
),
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
primaryTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Text(
isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟',
style: const TextStyle(color: AppColors.green),
),
],
),
Text(
primaryNote,
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(
label: resumeDialogue
? '继续对话'
: isReview
? '开始复习'
: '开始今天的学习',
onPressed: resumeDialogue
? onResumeLessonDialogue
: onStartPrimaryTask,
),
],
),
),
SectionCard(
child: SpacedColumn(
children: [
const Text(
'AI 情境对话',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'初次见面 · 4 个任务 · 文字或语音输入',
style: Theme.of(context).textTheme.bodyMedium,
),
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
],
),
),
const _FrameworkNote(),
],
),
);
}
}
class _FrameworkNote extends StatelessWidget {
const _FrameworkNote();
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warm,
borderRadius: BorderRadius.circular(14),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: AppColors.warmInk),
SizedBox(width: 8),
Expanded(
child: Text(
'文字回答可用于理解与写作练习;口语掌握须以录音或语音识别结果为准。可在“我的”配置 AI 服务。',
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
height: 1.45,
),
),
),
],
),
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key, required this.state, required this.onContinue});
final AppState state;
final VoidCallback onContinue;
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
late LearningGoal selectedGoal;
late int selectedMinutes;
@override
void initState() {
super.initState();
selectedGoal = widget.state.goal;
selectedMinutes = widget.state.dailyMinutes;
}
@override
Widget build(BuildContext context) {
return AppPage(
child: SpacedColumn(
spacing: 20,
children: [
const Eyebrow('欢迎'),
Text(
'每天 20 分钟,\n说出能用的英语。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
_ChoiceGroup<LearningGoal>(
title: '你的主要目标',
value: selectedGoal,
options: const {
LearningGoal.dailyLife: '日常生活',
LearningGoal.travel: '旅行',
LearningGoal.workStarter: '工作起步',
},
onChanged: (value) => setState(() => selectedGoal = value),
),
_ChoiceGroup<int>(
title: '每天学习多久?',
value: selectedMinutes,
options: const {10: '10 分钟', 20: '20 分钟', 30: '30 分钟'},
onChanged: (value) => setState(() => selectedMinutes = value),
),
PrimaryButton(
label: '继续',
onPressed: () {
widget.state
..setGoal(selectedGoal)
..setDailyMinutes(selectedMinutes);
widget.onContinue();
},
),
],
),
);
}
}
class PlacementPage extends StatefulWidget {
const PlacementPage({super.key, required this.state, required this.onStart});
final AppState state;
final VoidCallback onStart;
@override
State<PlacementPage> createState() => _PlacementPageState();
}
class _PlacementPageState extends State<PlacementPage> {
late PlacementLevel selected;
@override
void initState() {
super.initState();
selected = widget.state.placement;
}
@override
Widget build(BuildContext context) {
const labels = {
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return AppPage(
child: SpacedColumn(
spacing: 14,
children: [
const Eyebrow('第一步 · 约 3 分钟'),
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
const Text('选择最接近的状态,之后随时能调整。'),
for (final option in PlacementLevel.values)
_PlacementChoice(
option: option,
labels: labels[option]!,
isSelected: selected == option,
onTap: () => setState(() => selected = option),
),
PrimaryButton(
label: '开始 3 分钟定位',
onPressed: () {
widget.state.setPlacement(selected);
widget.onStart();
},
),
Center(
child: TextButton(
onPressed: widget.onStart,
child: const Text('直接从第一课开始'),
),
),
],
),
);
}
}
class _PlacementChoice extends StatelessWidget {
const _PlacementChoice({
required this.option,
required this.labels,
required this.isSelected,
required this.onTap,
});
final PlacementLevel option;
final (String, String) labels;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => SectionCard(
tint: isSelected ? AppColors.softGreen : null,
onTap: onTap,
child: Row(
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
color: isSelected ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
labels.$1,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(labels.$2, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class _ChoiceGroup<T> extends StatelessWidget {
const _ChoiceGroup({
required this.title,
required this.value,
required this.options,
required this.onChanged,
});
final String title;
final T value;
final Map<T, String> options;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SpacedColumn(
spacing: 10,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.entries
.map(
(entry) => ChoiceChip(
label: Text(entry.value),
selected: value == entry.key,
selectedColor: AppColors.softGreen,
onSelected: (_) => onChanged(entry.key),
),
)
.toList(),
),
],
),
);
}
}
@@ -0,0 +1,573 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/assessment_bank.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class ProgressPage extends StatelessWidget {
const ProgressPage({
super.key,
required this.state,
required this.onSettings,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onSettings;
final ValueChanged<AssessmentPack> onOpenAssessment;
@override
Widget build(BuildContext context) {
final recall = state.mastery.values
.where((item) => item.status == MasteryStatus.recall)
.length;
final recentEvidence = state.attemptEvidence.reversed.take(5).toList();
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('当前:A0 起步'),
Text('进度来自掌握证据。', style: Theme.of(context).textTheme.headlineMedium),
const Text('不是上完固定课数就升级。核心表达需要在不同时间、不同情境中独立用出。'),
_AbilityRow(
icon: Icons.psychology_outlined,
title: '已接触',
note: '${state.knownItemCount} 个词句或任务',
completed: state.knownItemCount > 0,
),
_AbilityRow(
icon: Icons.replay_outlined,
title: '能回忆',
note: '$recall 项正在巩固',
completed: recall > 0,
),
_AbilityRow(
icon: Icons.record_voice_over_outlined,
title: '可使用',
note: '${state.coreUsableCount} / 48 项核心内容已获得独立使用证据',
completed: state.coreUsableCount >= 48,
),
_AbilityRow(
icon: Icons.verified_outlined,
title: '已掌握(间隔复习)',
note: '${state.coreMasteredCount} / 30 项达到四次间隔复习要求',
completed: state.coreMasteredCount >= 30,
),
_AbilityRow(
icon: Icons.assignment_turned_in_outlined,
title: '两套四技能评估',
note: state.hasTwoValidAssessmentPasses
? '两套不同题组已在有效时间内通过'
: '尚需两套不同题组通过,间隔至少 24 小时',
completed: state.hasTwoValidAssessmentPasses,
),
SectionCard(
tint: state.dueReviewCount > 0
? AppColors.warm
: AppColors.softGreen,
child: Row(
children: [
Icon(
state.dueReviewCount > 0
? Icons.schedule
: Icons.check_circle_outline,
color: state.dueReviewCount > 0
? AppColors.warmInk
: AppColors.green,
),
const SizedBox(width: 10),
Expanded(
child: Text(
state.dueReviewCount > 0
? '${state.dueReviewCount} 项到期复习,完成后会更新掌握证据。'
: '目前没有到期复习。',
),
),
],
),
),
const SectionCard(
child: Text(
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
),
if (state.a0Passed)
const SectionCard(
tint: AppColors.softGreen,
child: Text('A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'),
),
if (recentEvidence.isNotEmpty) ...[
const Text(
'最近学习证据',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('用于解释进度,不是公开记录。'),
for (final evidence in recentEvidence)
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_evidenceLabel(evidence.outcome)} · ${evidence.skill}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (evidence.rawAnswer?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text('你的回答:${evidence.rawAnswer}'),
],
const SizedBox(height: 4),
Text(
_relativeTime(evidence.createdAt),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
const Text(
'A0 四技能评估',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'评估不会提供翻译或句框;第二套题组须在第一套通过至少 24 小时后完成。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final pack in a0AssessmentPacks)
SecondaryButton(
label: state.canStartAssessmentPack(pack.id)
? '开始 ${pack.id}'
: '${pack.id} 需等待第一套评估通过 24 小时',
onPressed: state.canStartAssessmentPack(pack.id)
? () => onOpenAssessment(pack)
: null,
),
SecondaryButton(label: '调整学习与 AI 设置', onPressed: onSettings),
],
),
);
}
static String _evidenceLabel(EvidenceKind outcome) => switch (outcome) {
EvidenceKind.independentSuccess => '独立完成',
EvidenceKind.assisted => '带提示完成',
EvidenceKind.languageError => '需要复核',
EvidenceKind.pending => '稍后完成',
EvidenceKind.exposure => '已查看',
};
static String _relativeTime(DateTime time) {
final difference = DateTime.now().difference(time);
if (difference.inMinutes < 1) return '刚刚';
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
if (difference.inDays < 1) return '${difference.inHours} 小时前';
return '${difference.inDays} 天前';
}
}
class _AbilityRow extends StatelessWidget {
const _AbilityRow({
required this.icon,
required this.title,
required this.note,
required this.completed,
});
final IconData icon;
final String title;
final String note;
final bool completed;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.state});
final AppState state;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
@override
void initState() {
super.initState();
endpoint = TextEditingController(text: widget.state.aiEndpoint);
model = TextEditingController(text: widget.state.aiModel);
}
@override
void dispose() {
endpoint.dispose();
model.dispose();
apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: const Text('学习设置')),
child: SpacedColumn(
spacing: 4,
children: [
_SettingTile(
title: '每日学习时间',
subtitle: '${widget.state.dailyMinutes} 分钟',
onTap: () => _chooseDuration(context),
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('默认显示中文提示'),
subtitle: const Text('A0 阶段开启'),
value: widget.state.showChineseHints,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleChineseHints,
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('保存原始录音'),
subtitle: const Text('录音功能启用后仅保存在本机'),
value: widget.state.keepRecordings,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleKeepRecordings,
),
_SettingTile(
title: '已保存的录音',
subtitle: '回听或删除本机英语练习录音',
onTap: () => _showRecordings(context),
),
_SettingTile(
title: '清除已保存的录音',
subtitle: '只删除本机原始音频,不影响学习进度或 AI 密钥',
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'AI 对话服务',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。密钥不在此页面保存。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
DropdownButtonFormField<AiProviderType>(
initialValue: widget.state.aiProvider,
decoration: const InputDecoration(
labelText: '服务类型',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: AiProviderType.mock,
child: Text('内置练习模式(无需网络)'),
),
DropdownMenuItem(
value: AiProviderType.openAi,
child: Text('OpenAI API'),
),
DropdownMenuItem(
value: AiProviderType.gemini,
child: Text('Gemini API'),
),
DropdownMenuItem(
value: AiProviderType.compatible,
child: Text('OpenAI 兼容 / CLIProxyAPI'),
),
],
onChanged: (value) {
if (value == null) return;
widget.state.setAiProvider(value);
if (value == AiProviderType.gemini &&
endpoint.text.trim().isEmpty) {
endpoint.text =
'https://generativelanguage.googleapis.com/v1beta';
model.text = model.text.trim().isEmpty
? 'gemini-2.5-flash'
: model.text;
}
},
),
TextField(
controller: endpoint,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Base URL(可选)',
hintText:
'OpenAI / 兼容: https://…/v1Gemini https://generativelanguage.googleapis.com/v1beta',
border: OutlineInputBorder(),
),
),
TextField(
controller: model,
decoration: const InputDecoration(
labelText: '模型名称(可选)',
hintText: '例如 gpt-4.1-mini',
border: OutlineInputBorder(),
),
),
TextField(
controller: apiKey,
obscureText: true,
decoration: const InputDecoration(
labelText: 'API Key(仅保存到设备安全存储)',
border: OutlineInputBorder(),
),
),
PrimaryButton(
label: '保存服务设置',
onPressed: () async {
widget.state.saveAiConfiguration(
endpoint: endpoint.text,
model: model.text,
);
if (apiKey.text.trim().isNotEmpty) {
await AiService.instance.saveApiKey(apiKey.text);
}
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
},
),
SecondaryButton(
label: '测试连接',
onPressed: () async {
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(result.message)));
},
),
const Divider(height: 28),
_DangerAction(
label: '清除学习进度',
message:
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。不会删除 API 密钥、AI 服务设置或已保存的原始录音;录音请使用下方独立操作删除。',
onConfirm: widget.state.clearProgress,
),
],
),
);
Future<void> _confirmDeleteRecordings(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清除已保存的录音?'),
content: const Text('这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(context, true),
child: const Text('清除'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final count = await VoiceService.instance.deleteAllRecordings();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
);
}
Future<void> _showRecordings(
BuildContext context,
) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: FutureBuilder<List<String>>(
future: VoiceService.instance.listRecordingPaths(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
final paths = snapshot.data!;
if (paths.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。'),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: SpacedColumn(
children: [
const Text(
'已保存的录音',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
for (final path in paths)
SectionCard(
child: Row(
children: [
const Icon(Icons.mic_none, color: AppColors.green),
const SizedBox(width: 10),
Expanded(
child: Text(
path.split('/').last.replaceAll('.m4a', ''),
),
),
IconButton(
tooltip: '回听',
onPressed: () =>
VoiceService.instance.playRecording(path),
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除',
onPressed: () async {
await VoiceService.instance.deleteRecording(path);
if (context.mounted) Navigator.pop(context);
},
icon: const Icon(Icons.delete_outline),
),
],
),
),
],
),
);
},
),
),
);
Future<void> _chooseDuration(BuildContext context) async {
final value = await showModalBottomSheet<int>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final minute in [10, 20, 30])
ListTile(
title: Text('$minute 分钟'),
trailing: widget.state.dailyMinutes == minute
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, minute),
),
],
),
),
);
if (value != null) widget.state.setDailyMinutes(value);
}
}
class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
required this.onTap,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
class _DangerAction extends StatelessWidget {
const _DangerAction({
required this.label,
required this.message,
required this.onConfirm,
});
final String label;
final String message;
final VoidCallback? onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
trailing: const Icon(Icons.chevron_right, color: Colors.redAccent),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = false;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const SizedBox(height: 12),
const Text(
'会清除:课程步骤、复习、掌握记录、学习证据、评估草稿和课程对话草稿。\n不会清除:已保存录音、AI API Key 与应用设置。',
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: const Text('我了解这些本机学习数据无法恢复'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
onConfirm?.call();
Navigator.pop(context);
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}
@@ -0,0 +1,697 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/ai_service.dart';
import '../../core/generated_content.dart';
import '../../core/models.dart';
import '../../core/review_feedback.dart';
import '../../core/a0_core.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class ReviewPage extends StatefulWidget {
const ReviewPage({
super.key,
required this.state,
required this.onFinished,
required this.onOpenAdaptiveLesson,
});
final AppState state;
final VoidCallback onFinished;
final VoidCallback onOpenAdaptiveLesson;
@override
State<ReviewPage> createState() => _ReviewPageState();
}
class _ReviewPageState extends State<ReviewPage> {
final controller = TextEditingController();
bool showHint = false;
bool usedHelp = false;
String? validationMessage;
bool generatingVariant = false;
bool generatingLesson = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _next(ReviewItem item, {required bool assisted}) {
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() => validationMessage = result.message);
return;
}
widget.state.completeReview(
item,
assisted: assisted,
rawAnswer: controller.text.trim(),
);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
}
Future<void> _generateVariant(ReviewItem item) async {
setState(() => generatingVariant = true);
final variant = await AiService.instance.generateReviewVariant(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
basePrompt: item.prompt,
);
if (!mounted) return;
setState(() => generatingVariant = false);
if (variant == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未获得合格变式,已继续使用本地审核题。')));
return;
}
widget.state.applyGeneratedReviewVariant(variant);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已生成并审核新题面,核心学习目标不变。')));
}
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
final label = a0CoreItems[item.id];
if (label == null) return;
setState(() => generatingLesson = true);
final lesson = await AiService.instance.generateAdaptiveLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
targetLabel: label,
);
if (lesson == null) {
if (mounted) setState(() => generatingLesson = false);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未生成合格补练,继续使用本地复习题。')));
}
return;
}
final approved = await AiService.instance.auditGeneratedLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
lesson: lesson,
);
if (!mounted) return;
setState(() => generatingLesson = false);
if (!approved) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('补练未通过独立审核,未保存。')));
return;
}
widget.state.cacheApprovedAdaptiveLesson(
lesson,
auditor: '${widget.state.aiProvider.name}:${widget.state.aiModel}',
);
widget.onOpenAdaptiveLesson();
}
@override
Widget build(BuildContext context) {
final item = widget.state.dueReviews.isEmpty
? null
: widget.state.dueReviews.first;
if (item == null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('今日复习已完成'),
Text(
'到期项目已安排下次复练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
],
),
);
}
final checkpoint =
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
final checkpointLabel = checkpoint >= 4
? '30 天抽查'
: '${checkpoint + 1} / 4 个间隔检查点';
return AppPage(
child: SpacedColumn(
children: [
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
Text('不看答案,试着回答。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'目标技能:${item.skill}',
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
checkpointLabel,
style: const TextStyle(color: AppColors.green, fontSize: 13),
),
if (item.isAiGenerated)
Row(
children: [
const Expanded(
child: Text(
'AI 生成题面 · 已通过客户端结构审核',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
),
TextButton(
onPressed: () {
widget.state.reportGeneratedReviewVariant(item);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')),
);
},
child: const Text('内容有问题'),
),
],
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Text(
'情境',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
Text(item.prompt, style: const TextStyle(fontSize: 19)),
],
),
),
TextField(
controller: controller,
minLines: 2,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '输入你会怎么回答',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
label: const Text('需要提示'),
onPressed: () => setState(() {
showHint = true;
usedHelp = true;
validationMessage = null;
}),
),
ActionChip(
label: const Text('稍后复习'),
onPressed: () {
widget.state.postponeReview(item);
setState(() {});
},
),
ActionChip(
label: const Text('暂时想不起来'),
onPressed: () {
widget.state.reportReviewFailure(item);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
},
),
ActionChip(
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
onPressed: generatingVariant
? null
: () => _generateVariant(item),
),
if (a0CoreItems.containsKey(item.id))
ActionChip(
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
onPressed: generatingLesson
? null
: () => _generateAdaptiveLesson(item),
),
],
),
if (showHint)
SectionCard(
tint: AppColors.warm,
child: Text(
'参考:${item.hint}\n目标:${item.target}',
style: const TextStyle(color: AppColors.warmInk),
),
),
if (validationMessage != null)
Text(
validationMessage!,
style: const TextStyle(color: AppColors.warmInk),
),
PrimaryButton(
label: usedHelp ? '带提示完成' : '我能独立回答',
onPressed: controller.text.trim().isEmpty
? null
: () => _next(item, assisted: usedHelp),
),
const Text(
'提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
);
}
}
/// Displays an audited AI-authored reinforcement lesson. It remains a
/// teaching activity: the existing local review and assessment systems own
/// all mastery decisions.
class AdaptiveLessonPage extends StatefulWidget {
const AdaptiveLessonPage({
super.key,
required this.state,
required this.onFinished,
});
final AppState state;
final VoidCallback onFinished;
@override
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
}
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final controller = TextEditingController();
int index = 0;
bool showReference = false;
String? answerFeedback;
bool listening = false;
bool usedVoice = false;
bool transcriptEdited = false;
bool transcriptConfirmed = false;
String lastTranscript = '';
bool recording = false;
bool playingRecording = false;
String? recordingPath;
@override
void initState() {
super.initState();
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null &&
widget.state.adaptiveLessonDraftId == lesson.lessonId) {
index = widget.state.adaptiveLessonDraftIndex.clamp(
0,
lesson.tasks.length,
);
controller.text = widget.state.adaptiveLessonDraftAnswer;
showReference = widget.state.adaptiveLessonDraftReferenceShown;
usedVoice = widget.state.adaptiveLessonDraftUsedVoice;
transcriptEdited = widget.state.adaptiveLessonDraftTranscriptEdited;
transcriptConfirmed = widget.state.adaptiveLessonDraftTranscriptConfirmed;
lastTranscript = widget.state.adaptiveLessonDraftOriginalTranscript;
recordingPath = widget.state.adaptiveLessonDraftRecordingPath;
}
}
@override
void dispose() {
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose();
super.dispose();
}
void _saveDraft(GeneratedLesson lesson) {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: index,
answer: controller.text,
referenceShown: showReference,
usedVoice: usedVoice,
transcriptEdited: transcriptEdited,
transcriptConfirmed: transcriptConfirmed,
originalTranscript: lastTranscript,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
}
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
final correct = matchesAdaptiveLessonAnswer(task, controller.text);
if (!showReference && !correct) {
setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。');
return;
}
widget.state.recordAdaptiveLessonTask(
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted: showReference,
correct: correct,
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
? 'speechToText'
: 'text',
recordingPath: widget.state.keepRecordings ? recordingPath : null,
originalTranscript: usedVoice ? lastTranscript : null,
transcriptConfirmed:
usedVoice && transcriptConfirmed && !transcriptEdited,
transcriptEdited: usedVoice && transcriptEdited,
);
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
final nextIndex = index + 1;
if (nextIndex >= lesson.tasks.length) {
widget.state.clearAdaptiveLessonDraft();
} else {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: nextIndex,
answer: '',
referenceShown: false,
originalTranscript: '',
recordingPath: null,
);
}
setState(() {
controller.clear();
showReference = false;
answerFeedback = null;
usedVoice = false;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = '';
recordingPath = null;
index = nextIndex;
});
}
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
});
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
});
if (!mounted) return;
setState(() => listening = ready);
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
@override
Widget build(BuildContext context) {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson == null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('AI 四技能补练'),
const Text('没有可用的已审核补练。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final done = index >= lesson.tasks.length;
if (done) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('补练已完成'),
Text(
'你完成了这组四技能教学补练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('这组 AI 内容只作教学复练;掌握度仍由本地复习与出口评估的有效证据决定。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final task = lesson.tasks[index];
final showStimulus = task.skill != 'listening';
return AppPage(
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
child: SpacedColumn(
children: [
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
if (widget.state.cachedAdaptiveLessonAuditedAt != null)
Text(
'审核:${widget.state.cachedAdaptiveLessonAuditor ?? '已配置服务'} · '
'${_formatAuditTime(widget.state.cachedAdaptiveLessonAuditedAt!)}',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Text(task.prompt, style: Theme.of(context).textTheme.headlineMedium),
if (showStimulus)
SectionCard(
tint: AppColors.softGreen,
child: Text(task.stimulus, style: const TextStyle(fontSize: 20)),
)
else
const SectionCard(
tint: AppColors.surfaceMuted,
child: Text('先播放音频,再输入你听到的答案。'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(task.stimulus),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(task.stimulus, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
],
),
TextField(
controller: controller,
minLines: 2,
onChanged: (_) {
if (usedVoice && controller.text != lastTranscript) {
transcriptEdited = true;
transcriptConfirmed = false;
}
_saveDraft(lesson);
setState(() {});
},
decoration: InputDecoration(
hintText: '输入或说出你的答案后,再继续',
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止语音输入' : '语音输入',
onPressed: _toggleListening,
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
),
),
border: OutlineInputBorder(),
),
),
if (task.skill == 'speaking')
SectionCard(
tint: AppColors.surfaceMuted,
child: SpacedColumn(
spacing: 8,
children: [
Text(
widget.state.keepRecordings
? '可录音回听并仅保存在本机;不会发送给 AI。'
: '可录音回听;离开本页后会自动删除。',
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: listening ? null : _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
],
),
),
if (usedVoice)
SectionCard(
tint: transcriptEdited || !transcriptConfirmed
? AppColors.warm
: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Text(
transcriptEdited
? '你修改了设备转写:本次将按文字教学练习保存。'
: transcriptConfirmed
? '已确认设备转写:会保留本次语音输入记录。'
: '请核对设备转写;确认前会按文字教学练习保存。',
),
if (!transcriptEdited && !transcriptConfirmed)
TextButton(
onPressed: () {
setState(() => transcriptConfirmed = true);
_saveDraft(lesson);
},
child: const Text('确认转写无误'),
),
],
),
),
if (answerFeedback != null)
Text(
answerFeedback!,
style: const TextStyle(color: AppColors.warmInk),
),
if (showReference)
SectionCard(
tint: AppColors.warm,
child: Text('参考表达:${task.answer}'),
)
else
TextButton(
onPressed: () {
setState(() => showReference = true);
_saveDraft(lesson);
},
child: const Text('需要帮助,查看参考表达'),
),
TextButton.icon(
onPressed: () {
widget.state.reportAdaptiveLesson(lesson);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离这组 AI 补练内容,并返回复习。')),
);
widget.onFinished();
},
icon: const Icon(Icons.flag_outlined),
label: const Text('内容有问题'),
),
PrimaryButton(
label: index == lesson.tasks.length - 1 ? '完成补练' : '下一项',
onPressed: controller.text.trim().isNotEmpty
? () => _advance(lesson, task)
: null,
),
],
),
);
}
String _skillLabel(String skill) => switch (skill) {
'listening' => '听力',
'speaking' => '口语',
'reading' => '阅读',
_ => '写作',
};
String _formatAuditTime(DateTime value) =>
'${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')} '
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}
@@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/assessment_bank.dart';
import '../../widgets/app_widgets.dart';
import '../dialogue/dialogue_flow.dart';
import '../assessment/assessment_page.dart';
import '../home/home_page.dart';
import '../lesson/lesson_flow.dart';
import '../progress/progress_pages.dart';
import '../review/review_page.dart';
class LearningShell extends StatefulWidget {
const LearningShell({super.key, required this.state});
final AppState state;
@override
State<LearningShell> createState() => _LearningShellState();
}
class _LearningShellState extends State<LearningShell> {
AppTab tab = AppTab.home;
var route = _ShellRoute.tab;
bool dialogueInLesson = false;
AssessmentPack? assessmentPack;
DialogueSummaryData? dialogueSummary;
void showTab(AppTab value) => setState(() {
tab = value;
route = _ShellRoute.tab;
});
void showLesson() => setState(() => route = _ShellRoute.lesson);
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
void showDialogue({bool inLesson = false}) => setState(() {
dialogueInLesson = inLesson;
route = _ShellRoute.dialogue;
});
void showSummary(DialogueSummaryData summary) => setState(() {
dialogueSummary = summary;
route = _ShellRoute.summary;
});
void showSettings() => setState(() => route = _ShellRoute.settings);
void showAssessment(AssessmentPack pack) => setState(() {
assessmentPack = pack;
route = _ShellRoute.assessmentPreparation;
});
void startAssessment() => setState(() => route = _ShellRoute.assessment);
void showAdaptiveLesson() =>
setState(() => route = _ShellRoute.adaptiveLesson);
@override
Widget build(BuildContext context) {
Widget body;
switch (route) {
case _ShellRoute.lesson:
body = LessonFlow(
state: widget.state,
onOpenDialogue: () => showDialogue(inLesson: true),
onFinish: () => showTab(AppTab.home),
);
case _ShellRoute.scene:
body = DialogueScenePage(onStart: showDialogue);
case _ShellRoute.dialogue:
body = DialoguePage(
state: widget.state,
isLessonDialogue: dialogueInLesson,
onFinished: dialogueInLesson
? (_) => showLesson()
: (summary) {
if (summary != null) showSummary(summary);
},
);
case _ShellRoute.summary:
body = DialogueSummaryPage(
summary: dialogueSummary!,
onHome: () => showTab(AppTab.home),
onLesson: showLesson,
onRetry: showDialogue,
);
case _ShellRoute.settings:
body = SettingsPage(state: widget.state);
case _ShellRoute.adaptiveLesson:
body = AdaptiveLessonPage(
state: widget.state,
onFinished: () => showTab(AppTab.review),
);
case _ShellRoute.assessment:
body = AssessmentPage(
state: widget.state,
pack: assessmentPack!,
onFinished: () => showTab(AppTab.progress),
onStartReplacement: showAssessment,
);
case _ShellRoute.assessmentPreparation:
body = AssessmentPreparationPage(
state: widget.state,
pack: assessmentPack!,
onStart: startAssessment,
onBack: () => showTab(AppTab.progress),
);
case _ShellRoute.tab:
body = _tabContent();
}
return AnimatedBuilder(
animation: widget.state,
builder: (context, _) => Scaffold(
body: body,
bottomNavigationBar: route == _ShellRoute.tab
? NavigationBar(
selectedIndex: tab.index,
height: 70,
indicatorColor: AppColors.softGreen,
onDestinationSelected: (index) => showTab(AppTab.values[index]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: '学习',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: '对话',
),
NavigationDestination(
icon: Icon(Icons.refresh_outlined),
selectedIcon: Icon(Icons.refresh),
label: '复习',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
)
: null,
),
);
}
Widget _tabContent() {
switch (tab) {
case AppTab.home:
return HomePage(
state: widget.state,
onStartPrimaryTask: widget.state.reviewIsPrimary
? () => showTab(AppTab.review)
: showLesson,
onOpenDialogue: showDialogue,
onResumeLessonDialogue: () => showDialogue(inLesson: true),
);
case AppTab.learn:
return _LearningMap(
onOpenLesson: (id) {
widget.state.openLesson(id);
showLesson();
},
onStartReinforcement: () {
widget.state.scheduleA0Reinforcement();
showTab(AppTab.review);
},
onOpenReview: () => showTab(AppTab.review),
state: widget.state,
);
case AppTab.dialogue:
return DialogueScenePage(onStart: showDialogue);
case AppTab.review:
return ReviewPage(
state: widget.state,
onFinished: () => showTab(AppTab.home),
onOpenAdaptiveLesson: showAdaptiveLesson,
);
case AppTab.progress:
return ProgressPage(
state: widget.state,
onSettings: showSettings,
onOpenAssessment: showAssessment,
);
}
}
}
enum _ShellRoute {
tab,
lesson,
scene,
dialogue,
summary,
settings,
adaptiveLesson,
assessmentPreparation,
assessment,
}
class _LearningMap extends StatelessWidget {
const _LearningMap({
required this.onOpenLesson,
required this.onStartReinforcement,
required this.onOpenReview,
required this.state,
});
final ValueChanged<String> onOpenLesson;
final VoidCallback onStartReinforcement;
final VoidCallback onOpenReview;
final AppState state;
@override
Widget build(BuildContext context) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('学习地图 · 按掌握状态推进'),
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
const Text('每节课都围绕一个能完成的小任务。'),
if (state.reviewBacklog)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text(
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
style: const TextStyle(color: AppColors.warmInk),
),
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
],
),
),
for (final lesson in a0SeedLessons)
SectionCard(
tint: lesson.id == state.activeLessonId
? AppColors.softGreen
: null,
onTap:
state.isLessonUnlocked(lesson.id) &&
(!state.reviewBacklog ||
state.completedLessonIds.contains(lesson.id))
? () => onOpenLesson(lesson.id)
: null,
child: Row(
children: [
Icon(
state.completedLessonIds.contains(lesson.id)
? Icons.check_circle
: state.reviewBacklog
? Icons.lock_outline
: state.isLessonUnlocked(lesson.id)
? Icons.play_circle_outline
: Icons.lock_outline,
color: state.completedLessonIds.contains(lesson.id)
? AppColors.green
: AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${lesson.number} 课 · ${lesson.title}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(
lesson.segments.length > 1
? '${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}'
: lesson.outcome,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
),
if (state.completedLessonIds.length == a0SeedLessons.length)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Text(
'A0 巩固变式',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
PrimaryButton(
label: '安排一题巩固练习',
onPressed: onStartReinforcement,
),
],
),
),
],
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'core/app_state.dart';
import 'core/app_theme.dart';
import 'features/onboarding/onboarding_pages.dart';
import 'features/shell/learning_shell.dart';
void main() {
runApp(const KouyuEnglishApp());
}
class KouyuEnglishApp extends StatefulWidget {
const KouyuEnglishApp({super.key});
@override
State<KouyuEnglishApp> createState() => _KouyuEnglishAppState();
}
class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
final appState = AppState();
var onboardingStep = 0;
@override
void initState() {
super.initState();
appState.load();
}
@override
void dispose() {
appState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '开口英语',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: AnimatedBuilder(
animation: appState,
builder: (context, _) {
if (!appState.isLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (appState.onboardingComplete) {
return LearningShell(state: appState);
}
if (onboardingStep == 0) {
return WelcomePage(
state: appState,
onContinue: () => setState(() => onboardingStep = 1),
);
}
return PlacementPage(
state: appState,
onStart: () => appState.finishOnboarding(),
);
},
),
);
}
}
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import '../core/app_theme.dart';
class AppPage extends StatelessWidget {
const AppPage({
super.key,
required this.child,
this.appBar,
this.bottomNavigationBar,
});
final Widget child;
final PreferredSizeWidget? appBar;
final Widget? bottomNavigationBar;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
body: SafeArea(
top: appBar == null,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: child,
),
),
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({
super.key,
required this.child,
this.tint,
this.onTap,
this.padding = const EdgeInsets.all(14),
});
final Widget child;
final Color? tint;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Material(
color: tint ?? AppColors.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(16),
),
child: content,
),
),
);
}
}
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? const SizedBox.shrink() : Icon(icon),
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: AppColors.green,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
}
class SecondaryButton extends StatelessWidget {
const SecondaryButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.ink,
side: const BorderSide(color: AppColors.line),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Text(label),
),
);
}
}
class Eyebrow extends StatelessWidget {
const Eyebrow(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 13,
),
);
}
class SpacedColumn extends StatelessWidget {
const SpacedColumn({super.key, required this.children, this.spacing = 12});
final List<Widget> children;
final double spacing;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index < children.length - 1) SizedBox(height: spacing),
],
],
);
}
@@ -0,0 +1,342 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../core/app_state.dart';
import '../core/app_theme.dart';
import '../core/ai_service.dart';
import '../core/models.dart';
import '../core/seed_courses.dart';
import '../core/voice_service.dart';
import 'app_widgets.dart';
List<VocabularyItem> get courseLexiconEntries {
final entries = <VocabularyItem>[
...a0SeedLessons.expand((lesson) => lesson.vocabulary),
...a0SegmentVocabulary.values.expand((items) => items),
];
final seen = <String>{};
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
..sort((a, b) => b.word.length.compareTo(a.word.length));
}
/// Finds a course item by exact query, then by the longest known phrase in it.
VocabularyItem? findCourseLexicon(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
return pattern.hasMatch(normalized);
}, orElse: () => null);
}
/// Inline course text that lets a learner tap a known word or phrase without
/// leaving the current task. Longest phrases are matched before their words.
class LexiconText extends StatefulWidget {
const LexiconText(
this.text, {
super.key,
required this.state,
this.style,
this.textAlign,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
@override
State<LexiconText> createState() => _LexiconTextState();
}
class _LexiconTextState extends State<LexiconText> {
final List<TapGestureRecognizer> _recognizers = [];
String selectedText = '';
@override
void dispose() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
final entries = courseLexiconEntries;
if (entries.isEmpty) return Text(widget.text, style: widget.style);
final expression = RegExp(
r'(?<![a-zA-Z0-9])(?:' +
entries
.map(
(item) => RegExp.escape(item.word).replaceAll('', "[']"),
)
.join('|') +
r')(?![a-zA-Z0-9])',
caseSensitive: false,
);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in expression.allMatches(widget.text)) {
if (match.start > cursor) {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
}
final matched = widget.text.substring(match.start, match.end);
final entry = findCourseLexicon(matched);
if (entry == null) {
spans.add(TextSpan(text: matched));
} else {
final recognizer = TapGestureRecognizer()
..onTap = () => showLexiconLookup(
context,
state: widget.state,
initialText: entry.word,
);
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: matched,
recognizer: recognizer,
style: const TextStyle(
color: AppColors.green,
decoration: TextDecoration.underline,
decorationColor: AppColors.green,
),
),
);
}
cursor = match.end;
}
if (cursor < widget.text.length) {
spans.add(TextSpan(text: widget.text.substring(cursor)));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
onSelectionChanged: (selection, _) {
final text = selection.isValid && !selection.isCollapsed
? widget.text.substring(selection.start, selection.end).trim()
: '';
if (text != selectedText && mounted) {
setState(() => selectedText = text);
}
},
),
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
),
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
],
);
}
}
Future<void> showLexiconLookup(
BuildContext context, {
required AppState state,
String initialText = '',
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
_LexiconLookupSheet(state: state, initialText: initialText),
);
class _LexiconLookupSheet extends StatefulWidget {
const _LexiconLookupSheet({required this.state, required this.initialText});
final AppState state;
final String initialText;
@override
State<_LexiconLookupSheet> createState() => _LexiconLookupSheetState();
}
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
late final TextEditingController controller;
VocabularyItem? entry;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
: null;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
?.definition;
temporaryError = null;
});
Future<void> _requestTemporaryDefinition() async {
final text = controller.text.trim();
if (text.isEmpty || widget.state.aiProvider.name == 'mock') {
setState(() => temporaryError = '未配置 AI 服务时,只能查询本地已审核课程词典。');
return;
}
setState(() {
requestingTemporaryDefinition = true;
temporaryError = null;
});
final definition = await AiService.instance.temporaryDefinition(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingTemporaryDefinition = false;
temporaryDefinition = definition;
temporaryError = definition == null ? '暂时无法查询;结果不会被编造。' : null;
});
if (definition != null) {
widget.state.cacheTemporaryDefinition(
query: text,
definition: definition,
);
}
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SpacedColumn(
children: [
const Eyebrow('课程词典 · 短语优先'),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入或粘贴英文词、短语、句子',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _lookup,
),
),
),
if (entry == null) ...[
const SectionCard(
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
),
OutlinedButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text('待审核临时释义\n$temporaryDefinition'),
Text(
'仅保存在本机,不会加入复习或影响掌握度。',
style: Theme.of(context).textTheme.bodySmall,
),
TextButton.icon(
onPressed: () {
widget.state.removeTemporaryDefinition(controller.text);
setState(() => temporaryDefinition = null);
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('删除此临时释义'),
),
],
),
),
] else ...[
Text(
entry!.word,
style: Theme.of(context).textTheme.headlineMedium,
),
if (entry!.ipa != null)
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
SectionCard(
tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(entry!.word),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(entry!.word, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
],
),
PrimaryButton(
label: '加入复习',
onPressed: () {
widget.state.addSavedWord(entry!);
Navigator.pop(context);
},
),
],
],
),
),
);
}
+7
View File
@@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

Some files were not shown because too many files have changed in this diff Show More