diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..534d071 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Doc/A0-COURSE-PACK.md b/Doc/A0-COURSE-PACK.md new file mode 100644 index 0000000..4f55a8e --- /dev/null +++ b/Doc/A0-COURSE-PACK.md @@ -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 | A:Hello/Hi、I'm [name](My name is 为可选同义);B:Nice 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 | A:zero–five;B:six–ten、number、phone;C:问号码和报告三位号码 | 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 | A:Monday–Wednesday、问星期与回答;B:Thursday–Sunday 复用句型;C:eleven/twelve、time/o'clock、问时间与整点回答 | What day is it today?(今天星期几?)、What time is it?(几点?);按对应分段引入 | +| 9 | A:I like [thing]、Do you like [thing]?;B:Yes, 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. I’m Shen. | 你好。我是 Shen。 | +| 句 | My name is Shen. | 我的名字是 Shen。 | +| 句 | Nice to meet you. | 很高兴见到你。 | +| 句 | Nice to meet you, too. | 我也很高兴见到你。 | + +**发音焦点**:`I’m` 是 `I am` 的自然短说法;`nice to` 连读时 `to` 很轻。 +**微语法**:`I’m + 名字` 用来介绍自己;`My name is + 名字` 也是同样意思。两种任选一种即可。 + +### 课内步骤 + +1. **听懂**:播放 `Hello. I’m Mia. Nice to meet you.`;题目:Mia 在做什么?选项为“自我介绍 / 买东西 / 问时间”。正确答案:自我介绍。 +2. **词句跟读**:依次跟读 `Hello.`、`I’m Shen.`、`Nice to meet you.`。每句可慢放和重录。 +3. **阅读**: + ```text + Mia: Hello. I’m Mia. + Shen: Hi. I’m Shen. + Mia: Nice to meet you. + Shen: Nice to meet you, too. + ``` + 题目:谁叫 Mia?正确答案:第一位说话的人。 +4. **写一写**:填空 `Hello. I’m _____.`;用户填写自己的名字。 +5. **AI 对话**:AI:`Hi! I’m Mia. What’s your name?`;用户说姓名。AI:`Nice to meet you, [name].`;用户回应 `Nice to meet you, too.` + +**完成判定**:说出或输入姓名,并回应一次 `Nice to meet you`。 +**结课复习句**:`Hi. I’m _____.` + +--- + +## 第 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: What’s 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:`What’s 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? | 你好吗? | +| 句 | I’m good, thanks. | 我很好,谢谢。 | +| 句 | I’m okay. | 我还可以。 | +| 句 | I’m tired today. | 我今天有点累。 | + +**发音焦点**:`How are you?` 自然快读时三个词会连在一起;`thanks` 末尾 /s/ 要轻收尾。 +**微语法**:`I’m + 状态`:`I’m good / okay / tired.` 用 `I’m` 表示“我现在是……”。 + +### 课内步骤 + +1. **听懂**:听 `How are you today?`,选择“询问你的状态”。 +2. **选择回应**:问题 `How are you?`,从 `I’m good, thanks.`、`My name is Mia.`、`It is Monday.` 选正确回应。 +3. **跟读**:`How are you?`、`I’m okay.`、`I’m tired today.` +4. **阅读**: + ```text + Mia: Hi, Shen. How are you? + Shen: I’m good, thanks. How are you? + Mia: I’m okay. + ``` + 题目:Mia 状态如何?答案:okay。 +5. **写一写**:选择一个真实状态完成 `I’m ____ today.` +6. **AI 对话**:AI:`Hi, [name]. How are you today?`;用户回答并反问。AI 用一种状态回答。 + +**完成判定**:完成一次问候和一次 `I’m + 状态` 回答。 +**结课复习句**:`I’m good, thanks.` + +--- + +## 第 4 课:数字与电话号码 + +**任务目标**:能说 0–10,听懂并报出简单电话号码片段。 +**预计时间**:每段 10–15 分钟,共 3 段,可分多天。 +**情境**:练习填写虚拟三位号码,不输入真实联系电话。 + +### 学习内容 + +| 类型 | 内容 | 中文 | +|---|---|---| +| 数字 | zero, one, two, three, four | 0–4 | +| 数字 | five, six, seven, eight, nine, ten | 5–10 | +| 词 | number /ˈnʌmbə(r)/ | 号码 | +| 词 | phone /fəʊn/ | 电话 | +| 句 | What’s 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: What’s your phone number? + Shen: One-three-eight. + Mia: Thank you. + ``` + 题目:Shen 的号码是?答案:138。 +5. **写一写**:将 `139` 写成英文数字(`one-three-nine`)。 +6. **AI 对话**:AI:`What’s 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)/ | 水 | +| 句 | What’s this? | 这是什么? | +| 句 | It’s a book. | 这是一本书。 | +| 句 | It’s my phone. | 这是我的手机。 | + +**发音焦点**:`this` 的 /ð/ 舌尖轻触上下齿之间,气流通过,不用用力咬;`It's` 保留清楚的词尾,不额外加元音。 +**微语法**:`What's this?` 问一个近处物品;单个可数物品用 `It's a book/pen/...`。`my` 后不再加 a;water 本课说 `It's water.`,不套用 `a water`。 + +### 课内步骤 + +1. **看图听词**:6 张物品图,听词后点击对应图片。 +2. **听懂**:播放 `What’s this? It’s a key.`;选择图片“钥匙”。 +3. **跟读**:`What’s this?`、`It’s a pen.`、`It’s my bag.` +4. **阅读**: + ```text + Mia: What’s this? + Shen: It’s 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 描述并出示物品图:`What’s this?`;用户回答。连续 3 个物品后结束。 + +**完成判定**:正确说出或输入 3 个物品句。 +**结课复习句**:`It’s a ____.` + +--- + +## 第 6 课:我来自…… + +**任务目标**:能说自己来自哪里,并礼貌询问对方。 +**预计时间**:13 分钟。 +**情境**:在国际活动上认识新朋友。 + +### 学习内容 + +| 类型 | 内容 | 中文 | +|---|---|---| +| 词 | from /frɒm/ | 来自 | +| 词 | China /ˈtʃaɪnə/ | 中国 | +| 词 | Hong Kong /ˌhɒŋ ˈkɒŋ/ | 香港 | +| 词 | city /ˈsɪti/ | 城市 | +| 词 | country /ˈkʌntri/ | 国家 | +| 句 | Where are you from? | 你来自哪里? | +| 句 | I’m from Hong Kong. | 我来自香港。 | +| 句 | I’m from China. | 我来自中国。 | + +**发音焦点**:`from` 的元音短;`Hong Kong` 两个词都有重音。 +**微语法**:`I'm from + 地点` 表示“我来自……”。本课 China、Hong Kong 前不加 the;并非所有地点都这样,例如 the UK、the Netherlands。自选地点以已核对的表达为准。 + +### 课内步骤 + +1. **听懂**:听 `I’m from Hong Kong.`,选择“香港”。 +2. **句子拼装**:把 `I’m / from / China` 拖成正确句子。 +3. **跟读**:`Where are you from?` 和两个回答句。 +4. **阅读**: + ```text + Mia: Where are you from? + Shen: I’m 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。 + +**完成判定**:能够用 `I’m from + 地点` 回答,并至少问一次 `Where are you from?`。 +**结课复习句**:`I’m 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/ | 时间 | +| 词 | o’clock /əˈklɒk/ | ……点钟 | +| 句 | What day is it today? | 今天星期几? | +| 句 | It’s Monday. | 今天星期一。 | +| 句 | What time is it? | 现在几点? | +| 句 | It’s three o’clock. | 现在三点。 | + +**发音焦点**:`Wednesday` 通常是两个音节 /ˈwenz.deɪ/,重音在第一个音节;`o'clock` 重音在第二部分。 +**微语法**:回答星期和整点时,使用 `It's + 星期/整点时间`;日历日期不在本课。 + +### 课内步骤 + +1. **听辨**:A 段只练 Monday–Wednesday;B 段加入 Thursday–Sunday,各先看词听音再听辨。C 段先练 eleven/twelve,再随机听辨 1–12 整点。 +2. **听懂**:播放 `What day is it today? It’s Tuesday.`;选择 Tuesday。 +3. **跟读**:三句核心句,重点练 Wednesday。 +4. **阅读**: + ```text + Mia: What day is it today? + Shen: It’s Monday. + Mia: What time is it? + Shen: It’s three o’clock. + ``` + 题目:现在几点?答案: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 整点钟面。未教过的星期和数字不计错。 + +**完成判定**:正确完成一个星期回答和一个整点回答。 +**结课复习句**:`It’s _____ o’clock.` + +--- + +## 第 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 don’t. | 是的,我喜欢。/ 不,我不喜欢。 | + +**发音焦点**:`I like` 中 `I` 与 `like` 连贯;`don’t` 的尾音 /t/ 要轻收。 +**微语法**:`I like + 东西/活动` 表达喜好。问对方时用完整句 `Do you like + 东西?`;短答为 `Yes, I do.` 或 `No, I don’t.` + +### 课内步骤 + +1. **听懂**:听 `I like music.`,选“音乐”图。 +2. **判断**:问题 `Do you like tea?`,匹配 `Yes, I do.` 和 `No, I don’t.` 的中文含义。 +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.` | +| 姓名 | `I’m ...` / `My name is ...` / `What’s your name?` | +| 来自哪里 | `I’m from ...` / `Where are you from?` | +| 状态 | `How are you?` / `I’m good, thanks.` | +| 喜好 | `I like ...` / `Do you like ...?` | + +**发音焦点**:优先追求清楚、自然停顿和完整回应;无需追求口音。 +**微语法复盘**:所有表达都来自四个简单框架:`I’m + 信息`、`I like + 东西`、`What’s/Where/How + 问题`、`Do you like + 东西?`。 + +### 课内步骤 + +1. **听懂**:播放完整对话: + ```text + Alex: Hi! I’m Alex. What’s your name? + Shen: Hi! I’m Shen. Nice to meet you. + Alex: Nice to meet you, too. Where are you from? + Shen: I’m from Hong Kong. How are you? + Alex: I’m 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! I’m Alex. What’s your name?` + - 开场提问姓名,后续每轮只问一个问题:来自哪里 → 状态或喜好;用已预教中文界面提示“最后请问对方一个已学问题”,不直接展示英文问句。该目标说明不算答案提示。 + - AI 最多 5 个用户回答轮;若用户卡住,可给句框并标记辅助完成。 + - 唯一完成清单为:问候、姓名、地点、一项状态/喜好、用户反问。前四项完成后留出一次用户提问机会;AI 回答后再收尾,不能提前结束。若第 4 个回答轮仍缺项,第 5 轮只补一个目标;上限后仍缺项则标记待补练,不算任务成功。允许更早完成,反问可自然出现在任一轮。 +6. **总结重说**:从用户回答中选一句最有价值的句子,例如 `I’m from Hong Kong.`,让用户立即重说。 + +**完成判定**:表达问候、姓名、地点及一项状态/喜好,并反问一次;不以轮数或秒数独立判定能力。 +**本课边界**:仅为种子综合练习,不是 A0 结业考。A0 通过另须核心词句门槛和两次四技能评估,覆盖姓名拼读、数字、物品、家人、星期/时间及补救表达;未通过继续生成同阶段练习。 + +## A0 复习安排 + +| 各词句首次完成正式教学后(不等待整课结束) | 复习内容 | 形式 | +|---:|---|---| +| 第 1 天 | 本课关键句 | 30 秒 AI 微对话 + 重说 | +| 第 3 天 | 易错词或句型 | 听辨或替换词造句 | +| 第 7 天 | 场景迁移 | 相似但不同的 AI 情境对话 | +| 第 14 天 | 综合复习 | 2–3 分钟任务型对话 | + +如果同一个关键句连续两次未完成,则在次日再次出现,且先提供句子框架,再逐步隐藏提示。 + +上表为正常学习日程;逾期、辅助完成和技术失败按《学习引擎规格》处理。离线可用审核模板替代 AI 微对话,不能可靠判定的开放作答留待反馈。 + +## 内容入库检查 + +- 每个分段标明对应 A0-C 能力、核心词句 ID、准备词块和先修内容;不按主题标题自动推断已教内容。 +- 逐题核对参考答案、允许的等义回答与新词预算;题内英文干扰项也要在允许范围。 +- 为全部新增字母、星期、eleven/twelve、准备问句制作音频,抽听音文一致性;未有音频不发布为听力任务。 +- 将 A0 出口题组独立于种子课练习制作、审校,不能直接将第 10 课成绩当作阶段通过。 +- 本次修订纠正文本与教学边界,不代表音频、图片或完整运行题库已经制作完毕。 diff --git a/Doc/A0-STAGE-STANDARD.md b/Doc/A0-STAGE-STANDARD.md new file mode 100644 index 0000000..7e1fe0a --- /dev/null +++ b/Doc/A0-STAGE-STANDARD.md @@ -0,0 +1,110 @@ +# A0 阶段标准与出口评估 + +> stageVersion:a0-v1|rubricVersion:a0-exit-v1|2026-09-12 +> A0 是本应用的零基础起步级,不是正式 CEFR 认证。以下阈值为个人学习的初始操作规则,需结合实际使用复核;不承诺学习时长或真人沟通成功率。 + +## 1. 固定能力地图 + +“前置”表示开始新能力前已完成相应准备教学且做过一次有效练习,不要求每个前置词先完成 14 天复习。继续练旧能力不限次数。第 10 课只是综合练习,不是本评估的替代品。 + +| 能力 ID | 能力 | 前置 | 种子主题与补充要求 | +|---|---|---|---| +| A0-C01 | 问候、说姓名、询问姓名 | 无 | 第 1 课 | +| A0-C02 | 听辨并完整拼读所选姓名 | C01 | 第 2 课;按姓名补字母,允许虚拟姓名 | +| A0-C03 | 询问与表达状态 | C01 | 第 3 课 | +| A0-C04 | 听辨 0–10、报告三位数字片段 | 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-W01–W11 | zero, one, two, three, four, five, six, seven, eight, nine, ten | +| A0-W12–W18 | Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday | +| A0-W19–W24 | book, phone, pen, bag, key, water | +| A0-W25–W28 | mother, father, sister, brother | +| A0-W29–W32 | coffee, tea, music, movies | +| A0-W33–W35 | good, okay, tired | +| A0-W36–W40 | 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 替代 a;water 用 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. | 1–12 整点 | +| 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` 未达到句子表达要求;口语中问姓名允许直接报名字,但主任务仍需完成问候与其他信息。 diff --git a/Doc/AI-CONVERSATION-PRD.md b/Doc/AI-CONVERSATION-PRD.md new file mode 100644 index 0000000..7fddc91 --- /dev/null +++ b/Doc/AI-CONVERSATION-PRD.md @@ -0,0 +1,217 @@ +# 开口英语(暂定名)— AI 对话模块规格 + +> 版本:1.1|2026-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? +用户: I’d 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 句 | 2–3 句 | +| 额外新词 | 每轮最多 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. 数据与隐私 + +录音、转写文本和对话记录仅供你个人练习。优先保存在本机,并提供清空录音、对话记录和学习进度的入口。语音识别低置信度时,应用必须允许编辑,不能直接将其判为语言错误。 diff --git a/Doc/LEARNING-ENGINE-SPEC.md b/Doc/LEARNING-ENGINE-SPEC.md new file mode 100644 index 0000000..57d37a3 --- /dev/null +++ b/Doc/LEARNING-ENGINE-SPEC.md @@ -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 + 1;30 分钟: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` 的掌握证据不只是知道中文意思;还应能听懂它、看到图片说出它,并在点餐中说出 `I’d 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 个场景中完成目标; +- 能听懂慢速日常对话的主要信息; +- 能写 4–6 句连贯的短消息; +- 能完成约 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 场景? +- 我能否在新场景中替换并使用上周的关键句? +- 我是否能听懂本阶段材料的主要意思,而非只会背答案? +- 我在没听清时,能否用英语请求重复、放慢或确认意思? +- 我的关键信息是否能被稳定识别,而不因口音或偶尔的识别错误被误判? +- 我是否完成了阶段出口任务? +- 若没有完成,薄弱点是听、说、读、写还是词句回忆? + +检查结果只用于调整下一周的复习量、课程速度和对话难度,不用于给自己贴“学得好/不好”的标签。 diff --git a/Doc/M1-IMPLEMENTATION-CONTRACT.md b/Doc/M1-IMPLEMENTATION-CONTRACT.md new file mode 100644 index 0000000..4cf7b3d --- /dev/null +++ b/Doc/M1-IMPLEMENTATION-CONTRACT.md @@ -0,0 +1,162 @@ +# M1 开发与验收约定 + +> 版本:1.0|2026-09-12|个人自用,Flutter 手机优先,支持 iPhone、Android、Mac。 +> 本文件统一 M1 范围、数据契约、降级行为和验收。学习算法以《学习引擎规格》为准,A0 清单与评估以《A0 阶段标准》为准。其他文档不得另设升级门槛;调整时须同步更新并记录版本。 + +## 1. 冻结的范围 + +M1 是完整的 **A0 学习闭环**,不是全部 A0–B2 产品: + +- 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) diff --git a/Doc/M1-MANUAL-VALIDATION.md b/Doc/M1-MANUAL-VALIDATION.md new file mode 100644 index 0000000..eda89c1 --- /dev/null +++ b/Doc/M1-MANUAL-VALIDATION.md @@ -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 的存在替代这些验收。 | diff --git a/Doc/M1-WIREFRAMES.md b/Doc/M1-WIREFRAMES.md new file mode 100644 index 0000000..e174a49 --- /dev/null +++ b/Doc/M1-WIREFRAMES.md @@ -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 │ +│ 目标:说姓名和来自哪里 │ +│ │ +│ AI:What’s your name? │ +│ 我:My name is Shen. │ +│ AI:Where are you <点词>from? │ +│ │ +│ [提示] [慢一点] [翻译] [重说] │ +│ │ +│ [按住说话 🎙] [输入文字] │ +└──────────────────────────┘ +``` + +**输入**:语音(默认)或文字;语音转写发送前可编辑。低可信度时显示“我没有完全听清”,并提供“确认转写 / 修改文字 / 重新说”,不直接判错。 +**AI 行为**:只问当前等级能理解的单一问题,记住已回答信息,优先推进任务;只有无法理解、关键任务阻塞或用户点“提示”时才简短辅助。AI 消息中可点英文词/短语打开查词卡;关闭后保持同一会话轮次,不重新生成或重复提问。 +**验收**:完成 3–5 轮不超过 5 分钟;语音识别失败时不丢失上下文,也不记录为发音错误。 + +## P06 课程/对话总结 + +```text +┌──────────────────────────┐ +│ 你完成了自我介绍! │ +│ 你说出了姓名和来自哪里。 │ +│ │ +│ ✓ 做得好 │ +│ My name is Shen. │ +│ → 下次这样说 │ +│ I’m from Hong Kong. │ +│ │ +│ [录音重说] │ +│ [明天复习这句话] │ +│ [回到首页] │ +└──────────────────────────┘ +``` + +**规则**:总结显示教学完成与独立尝试结果的区别:独立完成/帮助下完成/待做/待确认。需要补救的目标合并到次日复习,已稳定项目保留原间隔,不因总结重说统一改成明日到期。 +**验收**:每次自由输出至少生成一个正向反馈与一个可复习项目;无错误时给一句可替换表达,而非强行挑错。 + +## P07 复习页 + +```text +┌──────────────────────────┐ +│ 今天复习 · 3 项 │ +│ │ +│ 昨天你在问候时练过: │ +│ AI:How 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 通用发音练习(跟读子状态) + +显示示范音、慢放、自己的录音、回听和可选的一条动作提示;标题为“对比听一听”,不显示“你的 /θ/ 错了”。用户可重录或返回,不因跳过影响升级;真实口语证据仍从独立任务采集。 diff --git a/Doc/PRODUCT-PRD.md b/Doc/PRODUCT-PRD.md new file mode 100644 index 0000000..5c0b7eb --- /dev/null +++ b/Doc/PRODUCT-PRD.md @@ -0,0 +1,336 @@ +# 开口英语(暂定名)首版产品 PRD + +> 版本:0.2|2026-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 与 Mac;Web、多人共享和自动跨设备同步不在 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:起步(1–10) + +| 编号 | 主题 | 课后能做什么 | +|---:|---|---| +| 1 | 你好,我是… | 打招呼并介绍姓名 | +| 2 | 字母与拼写姓名 | 拼读自己的名字 | +| 3 | 你好吗? | 问候并简单回应 | +| 4 | 数字与电话号码 | 分段学习 0–10 和虚拟三位号码片段 | +| 5 | 这是什么? | 说出常见物品名称 | +| 6 | 我来自… | 介绍国家/城市 | +| 7 | 我的家人 | 介绍一位家人 | +| 8 | 今天星期几? | 分段学习七天与 1–12 整点,不教日历日期 | +| 9 | 我喜欢… | 表达一项喜好 | +| 10 | A0 小对话 | 完成 45 秒自我介绍 | + +### A1:生存英语(11–30) + +| 编号 | 主题 | 课后能做什么 | +|---:|---|---| +| 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. 版本路线图 + +### M1:A0 个人学习闭环 + +完成 10 节 A0 种子课及必要分段、听说读写、基础 AI 写作反馈、受控对话、每日变式/补救、复习、掌握证据、进度及四技能出口评估;包含服务配置、离线降级和中断恢复。课程总量不限,未掌握持续同阶段补练。通过 A0 后若 A1 内容未就绪,显示待提供。完整范围与验收见《开发与验收约定》。 + +### M2:完整个人学习路径 + +在 M1 闭环基础上新增 A1 能力地图、核心清单、种子主题及出口评估,扩展生活场景与自由主题对话;可增加加密导出/导入。M1 已有的复习、纠错、场景页和进度不重复列为 M2 首次交付。 + +### M3:个性化与 A2 + +加入更细的能力诊断、学习计划调整、旅行/工作目标路径,以及 A2 课程。在你完成 A1 路径并确认现有内容和反馈确实满足个人学习需要后再启动。 + +## 12. 版权与内容来源 + +BBC Learning English、学习资源合集和新概念英语可用于研究信息架构、学习方法或作为外链推荐。不得未经授权抓取、复制、改编或在应用内重新分发它们的文本、音频、视频和练习。 + +首版课程、对话、插画与音频使用原创或许可覆盖当前用途的素材;纳入应用时即记录来源、许可范围和署名要求,不以未来上线为前提。 diff --git a/Doc/PROTOTYPE-DESIGN-SPEC.md b/Doc/PROTOTYPE-DESIGN-SPEC.md new file mode 100644 index 0000000..20197f7 --- /dev/null +++ b/Doc/PROTOTYPE-DESIGN-SPEC.md @@ -0,0 +1,78 @@ +# M1 全部原型设计说明 + +## 范围 + +本设计覆盖个人学习工具 M1 的全部核心页面与关键状态,适配 Flutter 的 iPhone、Android 手机与 macOS。手机界面为第一优先级;Mac 复用相同的信息结构,以双栏和更宽的内容区呈现。 + +范围以 [M1 开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md) 为准,新增状态的文字线框见 [M1 页面规格](M1-WIREFRAMES.md) P04a、P09–P14 及 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); +- 实时全双工通话、精细发音分数或公开分享录音。 diff --git a/Doc/TECHNICAL-ARCHITECTURE.md b/Doc/TECHNICAL-ARCHITECTURE.md new file mode 100644 index 0000000..7e08ba7 --- /dev/null +++ b/Doc/TECHNICAL-ARCHITECTURE.md @@ -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,后者须另行验证。 diff --git a/Doc/learning-app-prototype.html b/Doc/learning-app-prototype.html new file mode 100644 index 0000000..13ea20f --- /dev/null +++ b/Doc/learning-app-prototype.html @@ -0,0 +1,292 @@ +
+ +
+ +
+
+ + +
+
9:41● ● ●
+
+
早上好,Shen

今天,说 3 句英语。

第 1 课已完成。今天把姓名介绍说得更自然。

+

A0 起步 · 已完成 3 个核心任务

+
第 2 课 · 拼读姓名12 分钟
先认识 4 个词 · 听 · 说 · 读 · 写
+
快速练习 · 初次见面
3 分钟 AI 对话 · 介绍姓名与来自哪里
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+ +
diff --git a/kouyu_english/.gitignore b/kouyu_english/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/kouyu_english/.gitignore @@ -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 diff --git a/kouyu_english/.metadata b/kouyu_english/.metadata new file mode 100644 index 0000000..a21daf5 --- /dev/null +++ b/kouyu_english/.metadata @@ -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' diff --git a/kouyu_english/README.md b/kouyu_english/README.md new file mode 100644 index 0000000..7250248 --- /dev/null +++ b/kouyu_english/README.md @@ -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. diff --git a/kouyu_english/analysis_options.yaml b/kouyu_english/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/kouyu_english/analysis_options.yaml @@ -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 diff --git a/kouyu_english/android/.gitignore b/kouyu_english/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/kouyu_english/android/.gitignore @@ -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 diff --git a/kouyu_english/android/app/build.gradle.kts b/kouyu_english/android/app/build.gradle.kts new file mode 100644 index 0000000..f96c32c --- /dev/null +++ b/kouyu_english/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/kouyu_english/android/app/src/debug/AndroidManifest.xml b/kouyu_english/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/kouyu_english/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/kouyu_english/android/app/src/main/AndroidManifest.xml b/kouyu_english/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..88c6882 --- /dev/null +++ b/kouyu_english/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/android/app/src/main/kotlin/com/shen/kouyu_english/MainActivity.kt b/kouyu_english/android/app/src/main/kotlin/com/shen/kouyu_english/MainActivity.kt new file mode 100644 index 0000000..7013971 --- /dev/null +++ b/kouyu_english/android/app/src/main/kotlin/com/shen/kouyu_english/MainActivity.kt @@ -0,0 +1,5 @@ +package com.shen.kouyu_english + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/kouyu_english/android/app/src/main/res/drawable-v21/launch_background.xml b/kouyu_english/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/kouyu_english/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/kouyu_english/android/app/src/main/res/drawable/launch_background.xml b/kouyu_english/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/kouyu_english/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/kouyu_english/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/kouyu_english/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/kouyu_english/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/kouyu_english/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/kouyu_english/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/kouyu_english/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/kouyu_english/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/kouyu_english/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/kouyu_english/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/kouyu_english/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/kouyu_english/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/kouyu_english/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/kouyu_english/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/kouyu_english/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/kouyu_english/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/kouyu_english/android/app/src/main/res/values-night/styles.xml b/kouyu_english/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/kouyu_english/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/kouyu_english/android/app/src/main/res/values/styles.xml b/kouyu_english/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/kouyu_english/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/kouyu_english/android/app/src/profile/AndroidManifest.xml b/kouyu_english/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/kouyu_english/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/kouyu_english/android/build.gradle.kts b/kouyu_english/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/kouyu_english/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/kouyu_english/android/gradle.properties b/kouyu_english/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/kouyu_english/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/kouyu_english/android/gradle/wrapper/gradle-wrapper.properties b/kouyu_english/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/kouyu_english/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/kouyu_english/android/settings.gradle.kts b/kouyu_english/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/kouyu_english/android/settings.gradle.kts @@ -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") diff --git a/kouyu_english/ios/.gitignore b/kouyu_english/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/kouyu_english/ios/.gitignore @@ -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 diff --git a/kouyu_english/ios/Flutter/AppFrameworkInfo.plist b/kouyu_english/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/kouyu_english/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/kouyu_english/ios/Flutter/Debug.xcconfig b/kouyu_english/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/kouyu_english/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/kouyu_english/ios/Flutter/Release.xcconfig b/kouyu_english/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/kouyu_english/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/kouyu_english/ios/Podfile b/kouyu_english/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/kouyu_english/ios/Podfile @@ -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 diff --git a/kouyu_english/ios/Runner.xcodeproj/project.pbxproj b/kouyu_english/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2f04714 --- /dev/null +++ b/kouyu_english/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + DADC5D95F208953F58E7BDBF /* Pods */, + F3BED9287B2BA9C107D6A9B2 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + F3BED9287B2BA9C107D6A9B2 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 428EF87395C0C527F283B8C8 /* Pods_Runner.framework */, + AC5E6CF32F60E96545899A44 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/kouyu_english/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/kouyu_english/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kouyu_english/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/kouyu_english/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/ios/Runner.xcworkspace/contents.xcworkspacedata b/kouyu_english/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/kouyu_english/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/kouyu_english/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kouyu_english/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kouyu_english/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kouyu_english/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kouyu_english/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/kouyu_english/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/kouyu_english/ios/Runner/AppDelegate.swift b/kouyu_english/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/kouyu_english/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard b/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/ios/Runner/Base.lproj/Main.storyboard b/kouyu_english/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/kouyu_english/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/ios/Runner/Info.plist b/kouyu_english/ios/Runner/Info.plist new file mode 100644 index 0000000..a69b455 --- /dev/null +++ b/kouyu_english/ios/Runner/Info.plist @@ -0,0 +1,74 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kouyu English + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + kouyu_english + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSMicrophoneUsageDescription + 用于录制和转写你的英语练习,供你回听或确认;录音只保存在本机。 + NSSpeechRecognitionUsageDescription + 用于识别英语练习内容;你可以改用文字输入。 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/kouyu_english/ios/Runner/Runner-Bridging-Header.h b/kouyu_english/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/kouyu_english/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/kouyu_english/ios/Runner/SceneDelegate.swift b/kouyu_english/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/kouyu_english/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/kouyu_english/ios/RunnerTests/RunnerTests.swift b/kouyu_english/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/kouyu_english/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/kouyu_english/lib/core/a0_core.dart b/kouyu_english/lib/core/a0_core.dart new file mode 100644 index 0000000..4b65530 --- /dev/null +++ b/kouyu_english/lib/core/a0_core.dart @@ -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 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> _numbered( + String prefix, + List 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, + ); +} diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart new file mode 100644 index 0000000..b61c5c4 --- /dev/null +++ b/kouyu_english/lib/core/ai_service.dart @@ -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 saveApiKey(String value) async { + if (value.trim().isEmpty) { + await _secureStorage.delete(key: _keyName); + } else { + await _secureStorage.write(key: _keyName, value: value.trim()); + } + } + + Future 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 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 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 dialogueReply({ + required AiProviderType provider, + required String endpoint, + required String model, + required List> 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 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 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 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 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 && + 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; + 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; + 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 = {}; + 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 = []; + 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; + } + } +} diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart new file mode 100644 index 0000000..c0b25a2 --- /dev/null +++ b/kouyu_english/lib/core/app_state.dart @@ -0,0 +1,1704 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models.dart'; +import 'a0_core.dart'; +import 'generated_content.dart'; +import 'local_store.dart'; +import 'seed_courses.dart'; + +class AppState extends ChangeNotifier { + static const _storageKey = 'learning_state_v1'; + bool isLoaded = false; + bool _writing = false; + bool _dirty = false; + + // Widget/unit tests keep using the mock preference backend. Production uses + // SQLite through Drift; this also prevents test cases sharing a real device + // database between runs. + bool get _usesLegacyTestStore => + Platform.environment['FLUTTER_TEST'] == 'true'; + + AppState() { + addListener(_persist); + } + LearningGoal goal = LearningGoal.dailyLife; + int dailyMinutes = 20; + PlacementLevel placement = PlacementLevel.beginner; + bool onboardingComplete = false; + bool showChineseHints = true; + bool keepRecordings = false; + int completedLessons = 0; + String activeLessonId = 'a0-01'; + final Set completedLessonIds = {}; + final Set completedSegmentIds = {}; + final Set reportedAiVariantKeys = {}; + final Map activeSegmentIndexes = {}; + final List assessments = []; + AssessmentDraft? assessmentDraft; + DialogueDraft? dialogueDraft; + LessonStep lessonStep = LessonStep.preview; + int previewIndex = 0; + bool lessonListeningComplete = false; + bool lessonSpeakingComplete = false; + bool lessonReadingComplete = false; + bool lessonWritingComplete = false; + bool lessonDialogueComplete = false; + bool independentAttemptComplete = false; + bool independentAttemptAssisted = false; + bool independentAttemptSpoken = false; + String lessonWritingDraft = ''; + String independentAttemptDraft = ''; + AiProviderType aiProvider = AiProviderType.mock; + String aiEndpoint = ''; + String aiModel = ''; + String? cachedAdaptiveLessonRaw; + DateTime? cachedAdaptiveLessonAuditedAt; + String? cachedAdaptiveLessonAuditor; + String? adaptiveLessonDraftId; + int adaptiveLessonDraftIndex = 0; + String adaptiveLessonDraftAnswer = ''; + bool adaptiveLessonDraftReferenceShown = false; + bool adaptiveLessonDraftUsedVoice = false; + bool adaptiveLessonDraftTranscriptEdited = false; + bool adaptiveLessonDraftTranscriptConfirmed = false; + String adaptiveLessonDraftOriginalTranscript = ''; + String? adaptiveLessonDraftRecordingPath; + final Set reportedAdaptiveLessonIds = {}; + final Map temporaryLexicon = {}; + + final List reviewQueue = []; + final List attemptEvidence = []; + + final Map mastery = {}; + + List get dueReviews { + final now = DateTime.now(); + final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList() + ..sort((a, b) => a.dueAt.compareTo(b.dueAt)); + return due; + } + + int get dueReviewCount => dueReviews.length; + bool get reviewIsPrimary => dueReviewCount > 0; + int get reviewBudgetSeconds => switch (dailyMinutes) { + 10 => 3 * 60, + 30 => 8 * 60, + _ => 5 * 60, + }; + int get dueReviewEstimatedSeconds => dueReviewCount * 60; + bool get reviewBacklog { + final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)); + return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 || + dueReviews.any((item) => item.dueAt.isBefore(sevenDaysAgo)); + } + + bool get hasResumableLessonDialogue => + dialogueDraft != null && + dialogueDraft!.lessonId == activeLessonId && + !lessonDialogueComplete; + + int activeSegmentIndexFor(String lessonId) => + activeSegmentIndexes[lessonId] ?? 0; + + String get _activeSegmentId { + final lesson = lessonById(activeLessonId); + return lesson.segments[activeSegmentIndexFor(activeLessonId)].id; + } + + bool isSegmentComplete(String segmentId) => + completedSegmentIds.contains(segmentId); + + void completeSegment(String lessonId, int segmentIndex) { + final segments = lessonById(lessonId).segments; + if (segmentIndex < 0 || segmentIndex >= segments.length) return; + completedSegmentIds.add(segments[segmentIndex].id); + activeSegmentIndexes[lessonId] = (segmentIndex + 1).clamp( + 0, + segments.length - 1, + ); + notifyListeners(); + } + + int get knownItemCount => mastery.length; + + int get coreUsableCount => mastery.entries + .where( + (entry) => + a0CoreItems.containsKey(entry.key) && + (entry.value.status == MasteryStatus.use || + entry.value.status == MasteryStatus.master) && + !entry.value.needsReview, + ) + .length; + + int get coreMasteredCount => mastery.entries + .where( + (entry) => + a0CoreItems.containsKey(entry.key) && + entry.value.status == MasteryStatus.master && + !entry.value.needsReview, + ) + .length; + + bool get hasTwoValidAssessmentPasses { + final passed = assessments.where((record) => record.passed).toList() + ..sort((a, b) => b.completedAt.compareTo(a.completedAt)); + if (passed.length < 2) { + return false; + } + final latest = passed.first; + if (DateTime.now().difference(latest.completedAt) > + const Duration(days: 30)) { + return false; + } + return passed + .skip(1) + .any( + (record) => + record.packId != latest.packId && + latest.completedAt.difference(record.completedAt) >= + const Duration(hours: 24), + ); + } + + bool get a0Passed => + coreUsableCount >= 48 && + coreMasteredCount >= 30 && + hasTwoValidAssessmentPasses; + + bool canStartAssessmentPack(String packId) { + if (packId != 'A0-E2') return true; + final first = assessments + .where((record) => record.packId == 'A0-E1' && record.passed) + .firstOrNull; + return first != null && + DateTime.now().difference(first.completedAt) >= + const Duration(hours: 24); + } + + AssessmentRecord recordAssessment(AssessmentRecord record) { + final canonicalPackId = record.packId.endsWith('R') + ? record.packId.substring(0, record.packId.length - 1) + : record.packId; + final normalized = AssessmentRecord( + packId: canonicalPackId, + completedAt: record.completedAt, + results: record.results, + pendingSkills: record.pendingSkills, + ); + final previous = assessments + .where((item) => item.packId == canonicalPackId) + .firstOrNull; + final isWithinWindow = + previous != null && + record.completedAt.difference(previous.completedAt) <= + const Duration(days: 7); + final merged = AssessmentRecord( + packId: canonicalPackId, + completedAt: normalized.completedAt, + results: { + for (final skill in AssessmentSkill.values) + skill: + normalized.results[skill] == true || + (isWithinWindow && previous.results[skill] == true), + }, + pendingSkills: { + for (final skill in AssessmentSkill.values) + if (normalized.results[skill] != true && + (normalized.pendingSkills.contains(skill) || + (!normalized.results.containsKey(skill) && + isWithinWindow && + previous.pendingSkills.contains(skill)))) + skill, + }, + ); + assessments.removeWhere((item) => item.packId == canonicalPackId); + assessments.add(merged); + notifyListeners(); + return merged; + } + + void saveAssessmentDraft(AssessmentDraft draft) { + assessmentDraft = draft; + notifyListeners(); + } + + void clearAssessmentDraft() { + if (assessmentDraft == null) return; + assessmentDraft = null; + notifyListeners(); + } + + void saveDialogueDraft(DialogueDraft draft) { + dialogueDraft = draft; + notifyListeners(); + } + + void clearDialogueDraft() { + if (dialogueDraft == null) return; + dialogueDraft = null; + notifyListeners(); + } + + int get usableMasteryCount => mastery.values + .where( + (item) => + item.status == MasteryStatus.use || + item.status == MasteryStatus.master, + ) + .length; + + bool get lessonCanComplete => + lessonListeningComplete && + lessonSpeakingComplete && + lessonReadingComplete && + lessonWritingComplete && + lessonDialogueComplete && + independentAttemptComplete; + + Future load() async { + try { + String? raw; + if (!_usesLegacyTestStore) { + raw = await LocalSnapshotStore.instance.read(); + } + if (raw == null) { + final preferences = await SharedPreferences.getInstance(); + raw = preferences.getString(_storageKey); + // One-time migration from builds that used SharedPreferences only. + if (raw != null && !_usesLegacyTestStore) { + await LocalSnapshotStore.instance.write(raw); + } + } + if (raw != null) _restore(jsonDecode(raw) as Map); + } catch (_) { + // A corrupt local cache must never prevent access to offline lessons. + } finally { + isLoaded = true; + notifyListeners(); + } + } + + void _restore(Map data) { + onboardingComplete = + data['onboardingComplete'] as bool? ?? onboardingComplete; + goal = _enumValue(LearningGoal.values, data['goal'] as String?, goal); + placement = _enumValue( + PlacementLevel.values, + data['placement'] as String?, + placement, + ); + dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes; + showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints; + keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings; + aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint; + aiModel = data['aiModel'] as String? ?? aiModel; + cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?; + cachedAdaptiveLessonAuditedAt = DateTime.tryParse( + data['cachedAdaptiveLessonAuditedAt'] as String? ?? '', + ); + cachedAdaptiveLessonAuditor = + data['cachedAdaptiveLessonAuditor'] as String?; + adaptiveLessonDraftId = data['adaptiveLessonDraftId'] as String?; + adaptiveLessonDraftIndex = data['adaptiveLessonDraftIndex'] as int? ?? 0; + adaptiveLessonDraftAnswer = + data['adaptiveLessonDraftAnswer'] as String? ?? ''; + adaptiveLessonDraftReferenceShown = + data['adaptiveLessonDraftReferenceShown'] as bool? ?? false; + adaptiveLessonDraftUsedVoice = + data['adaptiveLessonDraftUsedVoice'] as bool? ?? false; + adaptiveLessonDraftTranscriptEdited = + data['adaptiveLessonDraftTranscriptEdited'] as bool? ?? false; + adaptiveLessonDraftTranscriptConfirmed = + data['adaptiveLessonDraftTranscriptConfirmed'] as bool? ?? false; + adaptiveLessonDraftOriginalTranscript = + data['adaptiveLessonDraftOriginalTranscript'] as String? ?? ''; + adaptiveLessonDraftRecordingPath = + data['adaptiveLessonDraftRecordingPath'] as String?; + reportedAdaptiveLessonIds + ..clear() + ..addAll( + (data['reportedAdaptiveLessonIds'] as List? ?? const []) + .whereType(), + ); + final savedTemporaryLexicon = data['temporaryLexicon'] as List?; + if (savedTemporaryLexicon != null) { + temporaryLexicon + ..clear() + ..addEntries( + savedTemporaryLexicon + .whereType>() + .map((item) { + final query = item['query'] as String? ?? ''; + return MapEntry( + _normalizeLexiconQuery(query), + TemporaryLexiconEntry( + query: query, + definition: item['definition'] as String? ?? '', + provider: item['provider'] as String? ?? 'unknown', + model: item['model'] as String? ?? '', + createdAt: + DateTime.tryParse(item['createdAt'] as String? ?? '') ?? + DateTime.now(), + ), + ); + }) + .where( + (entry) => + entry.key.isNotEmpty && entry.value.definition.isNotEmpty, + ), + ); + } + aiProvider = _enumValue( + AiProviderType.values, + data['aiProvider'] as String?, + aiProvider, + ); + lessonStep = _enumValue( + LessonStep.values, + data['lessonStep'] as String?, + lessonStep, + ); + previewIndex = data['previewIndex'] as int? ?? previewIndex; + completedLessons = data['completedLessons'] as int? ?? completedLessons; + activeLessonId = data['activeLessonId'] as String? ?? activeLessonId; + completedLessonIds + ..clear() + ..addAll( + (data['completedLessonIds'] as List? ?? const []) + .whereType(), + ); + completedSegmentIds + ..clear() + ..addAll( + (data['completedSegmentIds'] as List? ?? const []) + .whereType(), + ); + reportedAiVariantKeys + ..clear() + ..addAll( + (data['reportedAiVariantKeys'] as List? ?? const []) + .whereType(), + ); + final savedSegmentIndexes = + data['activeSegmentIndexes'] as Map?; + if (savedSegmentIndexes != null) { + activeSegmentIndexes + ..clear() + ..addAll( + savedSegmentIndexes.map( + (key, value) => MapEntry(key, value as int? ?? 0), + ), + ); + } + lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false; + lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false; + lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false; + lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false; + lessonDialogueComplete = data['lessonDialogueComplete'] as bool? ?? false; + independentAttemptComplete = + data['independentAttemptComplete'] as bool? ?? false; + independentAttemptAssisted = + data['independentAttemptAssisted'] as bool? ?? false; + independentAttemptSpoken = + data['independentAttemptSpoken'] as bool? ?? false; + lessonWritingDraft = data['lessonWritingDraft'] as String? ?? ''; + independentAttemptDraft = data['independentAttemptDraft'] as String? ?? ''; + final reviews = data['reviews'] as List?; + if (reviews != null) { + reviewQueue + ..clear() + ..addAll( + reviews.whereType>().map(_reviewFromJson), + ); + } + final savedEvidence = data['attemptEvidence'] as List?; + if (savedEvidence != null) { + attemptEvidence + ..clear() + ..addAll( + savedEvidence.whereType>().map( + (item) => AttemptEvidence( + id: item['id'] as String? ?? '', + itemId: item['itemId'] as String? ?? '', + taskId: item['taskId'] as String? ?? '', + skill: item['skill'] as String? ?? '', + inputMode: item['inputMode'] as String? ?? 'text', + outcome: _enumValue( + EvidenceKind.values, + item['outcome'] as String?, + EvidenceKind.pending, + ), + createdAt: + DateTime.tryParse(item['createdAt'] as String? ?? '') ?? + DateTime.now(), + rawAnswer: item['rawAnswer'] as String?, + recordingPath: item['recordingPath'] as String?, + assisted: item['assisted'] as bool? ?? false, + variantIndex: item['variantIndex'] as int? ?? 0, + originalTranscript: item['originalTranscript'] as String?, + transcriptConfirmed: + item['transcriptConfirmed'] as bool? ?? false, + transcriptEdited: item['transcriptEdited'] as bool? ?? false, + ), + ), + ); + } + final savedMastery = data['mastery'] as List?; + if (savedMastery != null) { + mastery + ..clear() + ..addEntries( + savedMastery.whereType>().map((item) { + final id = item['id'] as String; + return MapEntry( + id, + MasteryItem( + id: id, + label: item['label'] as String? ?? id, + status: _enumValue( + MasteryStatus.values, + item['status'] as String?, + MasteryStatus.newItem, + ), + evidence: (item['evidence'] as List? ?? const []) + .whereType() + .map( + (name) => _enumValue( + EvidenceKind.values, + name, + EvidenceKind.pending, + ), + ) + .toList(), + needsReview: item['needsReview'] as bool? ?? false, + checkpoint: item['checkpoint'] as int? ?? 0, + firstTaughtAt: DateTime.tryParse( + item['firstTaughtAt'] as String? ?? '', + ), + ), + ); + }), + ); + } + final savedAssessments = data['assessments'] as List?; + if (savedAssessments != null) { + assessments + ..clear() + ..addAll( + savedAssessments.whereType>().map((item) { + final rawResults = + item['results'] as Map? ?? const {}; + return AssessmentRecord( + packId: item['packId'] as String, + completedAt: + DateTime.tryParse(item['completedAt'] as String? ?? '') ?? + DateTime.now(), + results: { + for (final skill in AssessmentSkill.values) + skill: rawResults[skill.name] == true, + }, + pendingSkills: + (item['pendingSkills'] as List? ?? const []) + .whereType() + .map( + (name) => _enumValue( + AssessmentSkill.values, + name, + AssessmentSkill.speaking, + ), + ) + .toSet(), + ); + }), + ); + } + final savedDraft = data['assessmentDraft'] as Map?; + if (savedDraft != null) { + assessmentDraft = AssessmentDraft( + packId: savedDraft['packId'] as String, + taskIndex: savedDraft['taskIndex'] as int? ?? 0, + results: (savedDraft['results'] as Map? ?? const {}) + .map((key, value) => MapEntry(key, value == true)), + ); + } + final savedDialogue = data['dialogueDraft'] as Map?; + if (savedDialogue != null) { + dialogueDraft = DialogueDraft( + lessonId: savedDialogue['lessonId'] as String, + stage: savedDialogue['stage'] as int? ?? 0, + usedHelp: savedDialogue['usedHelp'] as bool? ?? false, + turns: (savedDialogue['turns'] as List? ?? const []) + .whereType>() + .map( + (turn) => DialogueTurn( + text: turn['text'] as String, + isLearner: turn['isLearner'] as bool? ?? false, + ), + ) + .toList(), + ); + } + } + + T _enumValue(List values, String? name, T fallback) => + values.where((value) => value.name == name).firstOrNull ?? fallback; + + ReviewItem _reviewFromJson(Map data) => ReviewItem( + id: data['id'] as String, + target: data['target'] as String, + prompt: data['prompt'] as String, + hint: data['hint'] as String, + dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(), + skill: data['skill'] as String, + attempts: data['attempts'] as int? ?? 0, + successfulReviews: data['successfulReviews'] as int? ?? 0, + variantIndex: data['variantIndex'] as int? ?? 0, + lastProgressedAt: DateTime.tryParse( + data['lastProgressedAt'] as String? ?? '', + ), + isAiGenerated: data['isAiGenerated'] as bool? ?? false, + ); + + void _persist() { + if (!isLoaded) return; + _dirty = true; + if (_writing) return; + _writing = true; + _dirty = false; + _writeSnapshot( + jsonEncode({ + 'onboardingComplete': onboardingComplete, + 'goal': goal.name, + 'placement': placement.name, + 'dailyMinutes': dailyMinutes, + 'showChineseHints': showChineseHints, + 'keepRecordings': keepRecordings, + 'aiEndpoint': aiEndpoint, + 'aiModel': aiModel, + 'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw, + 'cachedAdaptiveLessonAuditedAt': cachedAdaptiveLessonAuditedAt + ?.toIso8601String(), + 'cachedAdaptiveLessonAuditor': cachedAdaptiveLessonAuditor, + 'adaptiveLessonDraftId': adaptiveLessonDraftId, + 'adaptiveLessonDraftIndex': adaptiveLessonDraftIndex, + 'adaptiveLessonDraftAnswer': adaptiveLessonDraftAnswer, + 'adaptiveLessonDraftReferenceShown': adaptiveLessonDraftReferenceShown, + 'adaptiveLessonDraftUsedVoice': adaptiveLessonDraftUsedVoice, + 'adaptiveLessonDraftTranscriptEdited': + adaptiveLessonDraftTranscriptEdited, + 'adaptiveLessonDraftTranscriptConfirmed': + adaptiveLessonDraftTranscriptConfirmed, + 'adaptiveLessonDraftOriginalTranscript': + adaptiveLessonDraftOriginalTranscript, + 'adaptiveLessonDraftRecordingPath': adaptiveLessonDraftRecordingPath, + 'reportedAdaptiveLessonIds': reportedAdaptiveLessonIds.toList(), + 'temporaryLexicon': temporaryLexicon.values + .map( + (entry) => { + 'query': entry.query, + 'definition': entry.definition, + 'provider': entry.provider, + 'model': entry.model, + 'createdAt': entry.createdAt.toIso8601String(), + }, + ) + .toList(), + 'aiProvider': aiProvider.name, + 'lessonStep': lessonStep.name, + 'previewIndex': previewIndex, + 'completedLessons': completedLessons, + 'activeLessonId': activeLessonId, + 'completedLessonIds': completedLessonIds.toList(), + 'completedSegmentIds': completedSegmentIds.toList(), + 'reportedAiVariantKeys': reportedAiVariantKeys.toList(), + 'activeSegmentIndexes': activeSegmentIndexes, + 'lessonListeningComplete': lessonListeningComplete, + 'lessonSpeakingComplete': lessonSpeakingComplete, + 'lessonReadingComplete': lessonReadingComplete, + 'lessonWritingComplete': lessonWritingComplete, + 'lessonDialogueComplete': lessonDialogueComplete, + 'independentAttemptComplete': independentAttemptComplete, + 'independentAttemptAssisted': independentAttemptAssisted, + 'independentAttemptSpoken': independentAttemptSpoken, + 'lessonWritingDraft': lessonWritingDraft, + 'independentAttemptDraft': independentAttemptDraft, + 'reviews': reviewQueue + .map( + (item) => { + 'id': item.id, + 'target': item.target, + 'prompt': item.prompt, + 'hint': item.hint, + 'dueAt': item.dueAt.toIso8601String(), + 'skill': item.skill, + 'attempts': item.attempts, + 'successfulReviews': item.successfulReviews, + 'variantIndex': item.variantIndex, + 'lastProgressedAt': item.lastProgressedAt?.toIso8601String(), + 'isAiGenerated': item.isAiGenerated, + }, + ) + .toList(), + 'mastery': mastery.values + .map( + (item) => { + 'id': item.id, + 'label': item.label, + 'status': item.status.name, + 'evidence': item.evidence.map((value) => value.name).toList(), + 'needsReview': item.needsReview, + 'checkpoint': item.checkpoint, + 'firstTaughtAt': item.firstTaughtAt?.toIso8601String(), + }, + ) + .toList(), + 'attemptEvidence': attemptEvidence + .map( + (entry) => { + 'id': entry.id, + 'itemId': entry.itemId, + 'taskId': entry.taskId, + 'skill': entry.skill, + 'inputMode': entry.inputMode, + 'outcome': entry.outcome.name, + 'createdAt': entry.createdAt.toIso8601String(), + 'rawAnswer': entry.rawAnswer, + 'recordingPath': entry.recordingPath, + 'assisted': entry.assisted, + 'variantIndex': entry.variantIndex, + 'originalTranscript': entry.originalTranscript, + 'transcriptConfirmed': entry.transcriptConfirmed, + 'transcriptEdited': entry.transcriptEdited, + }, + ) + .toList(), + 'assessments': assessments + .map( + (record) => { + 'packId': record.packId, + 'completedAt': record.completedAt.toIso8601String(), + 'results': { + for (final entry in record.results.entries) + entry.key.name: entry.value, + }, + 'pendingSkills': record.pendingSkills + .map((skill) => skill.name) + .toList(), + }, + ) + .toList(), + 'assessmentDraft': assessmentDraft == null + ? null + : { + 'packId': assessmentDraft!.packId, + 'taskIndex': assessmentDraft!.taskIndex, + 'results': assessmentDraft!.results, + }, + 'dialogueDraft': dialogueDraft == null + ? null + : { + 'lessonId': dialogueDraft!.lessonId, + 'stage': dialogueDraft!.stage, + 'usedHelp': dialogueDraft!.usedHelp, + 'turns': dialogueDraft!.turns + .map( + (turn) => { + 'text': turn.text, + 'isLearner': turn.isLearner, + }, + ) + .toList(), + }, + }), + ).catchError((_) => false).whenComplete(() { + _writing = false; + if (_dirty) _persist(); + }); + } + + Future _writeSnapshot(String payload) async { + if (_usesLegacyTestStore) { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString(_storageKey, payload); + return; + } + await LocalSnapshotStore.instance.write(payload); + } + + String _normalizeLexiconQuery(String value) => + value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + + TemporaryLexiconEntry? temporaryDefinitionFor(String query) => + temporaryLexicon[_normalizeLexiconQuery(query)]; + + void cacheTemporaryDefinition({ + required String query, + required String definition, + }) { + final key = _normalizeLexiconQuery(query); + final normalizedDefinition = definition.trim(); + if (key.isEmpty || normalizedDefinition.isEmpty) return; + temporaryLexicon[key] = TemporaryLexiconEntry( + query: query.trim(), + definition: normalizedDefinition, + provider: aiProvider.name, + model: aiModel.trim(), + createdAt: DateTime.now(), + ); + notifyListeners(); + } + + void removeTemporaryDefinition(String query) { + if (temporaryLexicon.remove(_normalizeLexiconQuery(query)) != null) { + notifyListeners(); + } + } + + void finishOnboarding() { + onboardingComplete = true; + notifyListeners(); + } + + void setGoal(LearningGoal value) { + goal = value; + notifyListeners(); + } + + void setDailyMinutes(int value) { + dailyMinutes = value; + notifyListeners(); + } + + void setPlacement(PlacementLevel value) { + placement = value; + notifyListeners(); + } + + void advanceLesson(LessonStep value) { + lessonStep = value; + notifyListeners(); + } + + void setPreviewIndex(int value) { + previewIndex = value < 0 ? 0 : value; + notifyListeners(); + } + + void setLessonWritingDraft(String value) { + if (lessonWritingDraft == value) return; + lessonWritingDraft = value; + notifyListeners(); + } + + void setIndependentAttemptDraft(String value) { + if (independentAttemptDraft == value) return; + independentAttemptDraft = value; + notifyListeners(); + } + + bool isLessonUnlocked(String id) { + final index = a0SeedLessons.indexWhere((lesson) => lesson.id == id); + return index == 0 || + (index > 0 && completedLessonIds.contains(a0SeedLessons[index - 1].id)); + } + + void openLesson(String id) { + if (!isLessonUnlocked(id)) return; + activeLessonId = id; + lessonStep = LessonStep.preview; + previewIndex = 0; + lessonWritingDraft = ''; + independentAttemptDraft = ''; + notifyListeners(); + } + + void completePreview() { + previewIndex = 0; + lessonStep = LessonStep.listening; + notifyListeners(); + } + + void completeListening() { + _introduceLessonTargets(); + lessonListeningComplete = true; + lessonStep = LessonStep.speaking; + notifyListeners(); + } + + void completeSpeaking({bool assisted = false}) { + lessonSpeakingComplete = true; + lessonStep = LessonStep.reading; + _recordLessonTaskEvidence( + targetIds: [_primaryTargetId], + taskSuffix: 'speaking', + skill: '口语表达', + outcome: assisted + ? EvidenceKind.assisted + : EvidenceKind.independentSuccess, + assisted: assisted, + ); + notifyListeners(); + } + + void completeReading() { + lessonReadingComplete = true; + lessonStep = LessonStep.writing; + _recordLessonTaskEvidence( + targetIds: [_primaryTargetId], + taskSuffix: 'reading', + skill: '阅读理解', + outcome: EvidenceKind.exposure, + ); + notifyListeners(); + } + + void completeWriting({bool assisted = false, String? rawAnswer}) { + lessonWritingComplete = true; + lessonWritingDraft = ''; + lessonStep = LessonStep.dialogue; + _recordLessonTaskEvidence( + targetIds: [_primaryTargetId], + taskSuffix: 'writing', + skill: '写作表达', + outcome: assisted + ? EvidenceKind.assisted + : EvidenceKind.independentSuccess, + rawAnswer: rawAnswer, + assisted: assisted, + ); + notifyListeners(); + } + + void completeLessonDialogue() { + lessonDialogueComplete = true; + lessonStep = LessonStep.independent; + _recordLessonTaskEvidence( + targetIds: [_primaryTargetId], + taskSuffix: 'dialogue', + skill: '受控对话', + outcome: EvidenceKind.assisted, + assisted: true, + ); + notifyListeners(); + } + + void completeIndependentAttempt({ + required bool assisted, + bool spoken = false, + String? rawAnswer, + String? recordingPath, + }) { + independentAttemptComplete = true; + independentAttemptDraft = ''; + independentAttemptAssisted = assisted; + independentAttemptSpoken = spoken && !assisted; + lessonStep = LessonStep.complete; + _recordLessonTaskEvidence( + targetIds: [_primaryTargetId], + taskSuffix: 'independent', + skill: spoken && !assisted ? '口语表达' : '写作表达', + inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text', + outcome: assisted + ? EvidenceKind.assisted + : EvidenceKind.independentSuccess, + rawAnswer: rawAnswer, + recordingPath: recordingPath, + assisted: assisted, + ); + notifyListeners(); + } + + void finishLesson() { + if (!lessonCanComplete) return; + completedLessonIds.add(activeLessonId); + completedLessons = completedLessonIds.length; + final currentIndex = a0SeedLessons.indexWhere( + (lesson) => lesson.id == activeLessonId, + ); + if (currentIndex >= 0 && currentIndex < a0SeedLessons.length - 1) { + activeLessonId = a0SeedLessons[currentIndex + 1].id; + } + _resetLessonFlow(); + notifyListeners(); + } + + void _resetLessonFlow() { + lessonStep = LessonStep.preview; + previewIndex = 0; + lessonListeningComplete = false; + lessonSpeakingComplete = false; + lessonReadingComplete = false; + lessonWritingComplete = false; + lessonDialogueComplete = false; + independentAttemptComplete = false; + independentAttemptAssisted = false; + independentAttemptSpoken = false; + lessonWritingDraft = ''; + independentAttemptDraft = ''; + } + + void finishCurrentLessonSegment() { + final lesson = lessonById(activeLessonId); + final index = activeSegmentIndexFor(activeLessonId); + if (index >= lesson.segments.length - 1) { + completeSegment(activeLessonId, index); + finishLesson(); + return; + } + completeSegment(activeLessonId, index); + _resetLessonFlow(); + notifyListeners(); + } + + void completeReview( + ReviewItem item, { + required bool assisted, + String? rawAnswer, + }) { + final index = reviewQueue.indexWhere( + (candidate) => candidate.id == item.id, + ); + if (index < 0) return; + final current = reviewQueue[index]; + final now = DateTime.now(); + // UI/network retries may still hold an old item instance. Only the + // currently due queue entry is allowed to produce evidence. + if (current.dueAt.isAfter(now)) return; + // A review is never silently discarded. Success earns a wider interval; + // an assisted answer gets another, different attempt tomorrow. + final progressedToday = + current.lastProgressedAt != null && + current.lastProgressedAt!.year == now.year && + current.lastProgressedAt!.month == now.month && + current.lastProgressedAt!.day == now.day; + final canProgress = !assisted && !progressedToday; + final nextSuccesses = canProgress + ? current.successfulReviews + 1 + : current.successfulReviews; + const intervals = [1, 2, 4]; + final intervalIndex = (nextSuccesses - 1) + .clamp(0, intervals.length - 1) + .toInt(); + final masteryItem = mastery[current.id]; + final days = assisted || !canProgress + ? 1 + : masteryItem?.needsReview == true && + masteryItem?.status == MasteryStatus.master + ? 7 + : nextSuccesses >= 4 + ? 30 + : intervals[intervalIndex]; + reviewQueue[index] = current.copyWith( + dueAt: DateTime.now().add(Duration(days: days)), + attempts: current.attempts + 1, + successfulReviews: nextSuccesses, + lastProgressedAt: canProgress ? now : current.lastProgressedAt, + ); + if (assisted) { + _recordEvidence(current.id, EvidenceKind.assisted); + } else { + _recordReviewSuccess(current.id, nextSuccesses); + } + _addAttemptEvidence( + current, + outcome: assisted + ? EvidenceKind.assisted + : EvidenceKind.independentSuccess, + assisted: assisted, + rawAnswer: rawAnswer, + ); + notifyListeners(); + } + + void reportReviewFailure(ReviewItem item) { + final index = reviewQueue.indexWhere( + (candidate) => candidate.id == item.id, + ); + if (index < 0) return; + final existing = + mastery[item.id] ?? + MasteryItem( + id: item.id, + label: item.target, + status: MasteryStatus.newItem, + evidence: const [], + ); + final secondFailure = existing.needsReview; + final nextCheckpoint = secondFailure + ? (existing.checkpoint - 1).clamp(0, 4).toInt() + : existing.checkpoint; + mastery[item.id] = existing.copyWith( + status: secondFailure + ? _statusForCheckpoint(nextCheckpoint) + : existing.status, + checkpoint: nextCheckpoint, + needsReview: true, + evidence: [...existing.evidence, EvidenceKind.languageError], + ); + reviewQueue[index] = item.copyWith( + dueAt: DateTime.now().add(const Duration(days: 1)), + attempts: item.attempts + 1, + successfulReviews: secondFailure + ? nextCheckpoint + : item.successfulReviews, + ); + _addAttemptEvidence(item, outcome: EvidenceKind.languageError); + notifyListeners(); + } + + void postponeReview(ReviewItem item) { + final index = reviewQueue.indexWhere( + (candidate) => candidate.id == item.id, + ); + if (index < 0) return; + reviewQueue[index] = item.copyWith( + dueAt: DateTime.now().add(const Duration(days: 1)), + attempts: item.attempts + 1, + ); + _addAttemptEvidence(item, outcome: EvidenceKind.pending); + notifyListeners(); + } + + void _addAttemptEvidence( + ReviewItem item, { + required EvidenceKind outcome, + bool assisted = false, + String? rawAnswer, + }) { + final now = DateTime.now(); + attemptEvidence.add( + AttemptEvidence( + id: 'review-${item.id}-${now.microsecondsSinceEpoch}', + itemId: item.id, + taskId: 'review-${item.id}', + skill: item.skill, + inputMode: 'text', + outcome: outcome, + createdAt: now, + rawAnswer: rawAnswer, + assisted: assisted, + variantIndex: item.variantIndex, + ), + ); + } + + void recordAssessmentAttempt({ + required String taskId, + required String skill, + required bool correct, + required String rawAnswer, + required bool spoken, + }) { + final now = DateTime.now(); + attemptEvidence.add( + AttemptEvidence( + id: 'assessment-$taskId-${now.microsecondsSinceEpoch}', + itemId: taskId, + taskId: taskId, + skill: skill, + inputMode: spoken ? 'speech-unedited-transcript' : 'text', + outcome: correct + ? EvidenceKind.independentSuccess + : EvidenceKind.languageError, + createdAt: now, + rawAnswer: rawAnswer, + ), + ); + notifyListeners(); + } + + void recordAssessmentPending({ + required String taskId, + required String skill, + required String reason, + }) { + final now = DateTime.now(); + attemptEvidence.add( + AttemptEvidence( + id: 'assessment-pending-$taskId-${now.microsecondsSinceEpoch}', + itemId: taskId, + taskId: taskId, + skill: skill, + inputMode: 'unavailable', + outcome: EvidenceKind.pending, + createdAt: now, + rawAnswer: reason, + ), + ); + notifyListeners(); + } + + void recordDialogueAttempt({ + required String taskId, + required String rawAnswer, + required bool assisted, + bool spoken = false, + String? recordingPath, + }) { + final now = DateTime.now(); + attemptEvidence.add( + AttemptEvidence( + id: 'dialogue-$taskId-${now.microsecondsSinceEpoch}', + itemId: _primaryTargetId, + taskId: taskId, + skill: '受控对话', + inputMode: spoken ? 'speech-unedited-transcript' : 'text', + outcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending, + createdAt: now, + rawAnswer: rawAnswer, + recordingPath: recordingPath, + assisted: assisted, + ), + ); + notifyListeners(); + } + + void addSavedWord(VocabularyItem item) { + if (reviewQueue.any((review) => review.id == item.id)) return; + reviewQueue.add( + ReviewItem( + id: item.id, + target: item.word, + prompt: item.example, + hint: item.meaning, + dueAt: DateTime.now().add(const Duration(days: 1)), + skill: '认识与回忆', + ), + ); + notifyListeners(); + } + + /// Adds one low-priority, non-core recap based on a completed independent + /// dialogue. It is deliberately separate from A0 denominator items. + void addDialogueRecap(String sentence) { + final now = DateTime.now(); + final day = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; + final id = 'dialogue-a0-meet-$day'; + if (reviewQueue.any((item) => item.id == id)) return; + reviewQueue.add( + ReviewItem( + id: id, + target: sentence, + prompt: '再用英语介绍一次自己。', + hint: '试着不用提示,说出你刚才表达的内容。', + dueAt: now.add(const Duration(days: 1)), + skill: '情境复练', + ), + ); + notifyListeners(); + } + + /// AI adaptive lessons are bounded teaching material. Only a locally + /// validated, unassisted answer can add limited teaching evidence; spaced + /// review checkpoints and stage assessment remain the source of `master`. + void recordAdaptiveLessonTask({ + required GeneratedLesson lesson, + required GeneratedLessonTask task, + required String rawAnswer, + required bool assisted, + required bool correct, + String inputMode = 'text', + String? recordingPath, + String? originalTranscript, + bool transcriptConfirmed = false, + bool transcriptEdited = false, + }) { + final stableTaskId = '${lesson.lessonId}-${task.taskId}'; + // A bounded adaptive lesson presents each task once. Protect against a + // double tap or a retried UI callback creating two independent successes + // for the same material revision. + if (attemptEvidence.any((entry) => entry.taskId == stableTaskId)) return; + final now = DateTime.now(); + // Treat the voice label as an evidence boundary, not caller-provided + // metadata. An edited transcript is a useful written learning response, + // but can never become speech evidence merely because a UI caller forgot + // to clear its confirmation flag. + final confirmedVoiceTranscript = + inputMode == 'speechToText' && transcriptConfirmed && !transcriptEdited; + attemptEvidence.add( + AttemptEvidence( + id: 'adaptive-${lesson.lessonId}-${task.taskId}-${now.microsecondsSinceEpoch}', + itemId: task.targetItemIds.single, + taskId: stableTaskId, + skill: switch (task.skill) { + 'listening' => '听力理解', + 'speaking' => '口语表达', + 'reading' => '阅读理解', + _ => '写作表达', + }, + inputMode: confirmedVoiceTranscript ? 'speechToText' : 'text', + outcome: assisted + ? EvidenceKind.assisted + : correct + ? EvidenceKind.independentSuccess + : EvidenceKind.pending, + createdAt: now, + rawAnswer: rawAnswer, + recordingPath: recordingPath, + assisted: assisted, + originalTranscript: originalTranscript, + transcriptConfirmed: confirmedVoiceTranscript, + transcriptEdited: transcriptEdited, + ), + ); + if (!assisted && correct) { + _recordEvidence( + task.targetItemIds.single, + EvidenceKind.independentSuccess, + ); + } + notifyListeners(); + } + + void scheduleA0Reinforcement() { + final candidates = a0CoreItems.keys.toList() + ..sort((left, right) { + final leftItem = mastery[left]; + final rightItem = mastery[right]; + final leftRank = leftItem?.needsReview == true + ? -1 + : leftItem?.checkpoint ?? 0; + final rightRank = rightItem?.needsReview == true + ? -1 + : rightItem?.checkpoint ?? 0; + return leftRank == rightRank + ? left.compareTo(right) + : leftRank.compareTo(rightRank); + }); + if (candidates.isEmpty) return; + final id = candidates.first; + final index = reviewQueue.indexWhere((item) => item.id == id); + if (index >= 0) { + final current = reviewQueue[index]; + final template = coreReviewVariant(id, current.variantIndex + 1); + reviewQueue[index] = current.copyWith( + prompt: template.prompt, + hint: template.hint, + skill: template.skill, + dueAt: DateTime.now(), + variantIndex: current.variantIndex + 1, + ); + } else { + final template = coreReviewVariant(id, 1); + reviewQueue.add( + ReviewItem( + id: id, + target: a0CoreItems[id]!, + prompt: template.prompt, + hint: template.hint, + dueAt: DateTime.now(), + skill: template.skill, + variantIndex: 1, + ), + ); + } + notifyListeners(); + } + + void applyGeneratedReviewVariant(GeneratedReviewVariant variant) { + final index = reviewQueue.indexWhere( + (item) => item.id == variant.targetItemId, + ); + if (index < 0) return; + final current = reviewQueue[index]; + reviewQueue[index] = current.copyWith( + prompt: variant.prompt, + hint: variant.expectedAnswer, + variantIndex: current.variantIndex + 1, + isAiGenerated: true, + ); + notifyListeners(); + } + + /// Removes an AI-authored review wording from use. If it had already been + /// answered, only evidence tied to that exact variant is invalidated and + /// its checkpoint contribution is removed; the stable core item remains. + void reportGeneratedReviewVariant(ReviewItem item) { + if (!item.isAiGenerated) return; + final index = reviewQueue.indexWhere( + (candidate) => candidate.id == item.id, + ); + if (index < 0) return; + final key = '${item.id}:${item.variantIndex}'; + if (!reportedAiVariantKeys.add(key)) return; + final invalidSuccesses = attemptEvidence + .where( + (evidence) => + evidence.itemId == item.id && + evidence.taskId == 'review-${item.id}' && + evidence.variantIndex == item.variantIndex && + evidence.outcome == EvidenceKind.independentSuccess, + ) + .length; + attemptEvidence.removeWhere( + (evidence) => + evidence.itemId == item.id && + evidence.taskId == 'review-${item.id}' && + evidence.variantIndex == item.variantIndex, + ); + if (invalidSuccesses > 0) _rebuildMasteryItem(item.id, forceReview: true); + final template = coreReviewVariant(item.id, item.variantIndex + 1); + reviewQueue[index] = item.copyWith( + prompt: template.prompt, + hint: template.hint, + skill: template.skill, + variantIndex: item.variantIndex + 1, + dueAt: DateTime.now(), + isAiGenerated: false, + ); + notifyListeners(); + } + + /// Rebuilds displayed mastery from immutable local attempt evidence. AI + /// responses never supply this state. It is safe to call after isolating a + /// bad generated variant because removed evidence can no longer contribute. + void rebuildMasteryFromEvidence() { + final ids = { + ...mastery.keys, + ...attemptEvidence.map((entry) => entry.itemId), + }; + for (final id in ids) { + _rebuildMasteryItem(id); + } + for (var index = 0; index < reviewQueue.length; index++) { + final item = reviewQueue[index]; + final rebuilt = mastery[item.id]; + if (rebuilt != null) { + reviewQueue[index] = item.copyWith( + successfulReviews: rebuilt.checkpoint, + ); + } + } + notifyListeners(); + } + + void _rebuildMasteryItem(String id, {bool forceReview = false}) { + final existing = mastery[id]; + final events = attemptEvidence.where((entry) => entry.itemId == id).toList() + ..sort((left, right) => left.createdAt.compareTo(right.createdAt)); + var checkpoint = 0; + var needsReview = forceReview; + var nonReviewSuccesses = 0; + DateTime? firstTaughtAt; + final progressedDays = {}; + for (final event in events) { + final isReview = event.taskId == 'review-$id'; + if (!isReview && + event.outcome == EvidenceKind.exposure && + (firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) { + firstTaughtAt = event.createdAt; + } + if (!isReview && event.outcome == EvidenceKind.independentSuccess) { + nonReviewSuccesses++; + continue; + } + if (!isReview) continue; + if (event.outcome == EvidenceKind.independentSuccess) { + final day = + '${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}'; + if (progressedDays.add(day)) checkpoint = (checkpoint + 1).clamp(0, 4); + needsReview = false; + } else if (event.outcome == EvidenceKind.languageError) { + if (needsReview) checkpoint = (checkpoint - 1).clamp(0, 4); + needsReview = true; + } + } + final independentLevel = nonReviewSuccesses.clamp(0, 3); + final level = checkpoint > independentLevel ? checkpoint : independentLevel; + mastery[id] = MasteryItem( + id: id, + label: existing?.label ?? a0CoreItems[id] ?? id, + status: _statusForCheckpoint(level), + checkpoint: checkpoint, + needsReview: needsReview, + evidence: events.map((event) => event.outcome).toList(), + firstTaughtAt: firstTaughtAt ?? existing?.firstTaughtAt, + ); + } + + void setAiProvider(AiProviderType value) { + aiProvider = value; + notifyListeners(); + } + + void saveAiConfiguration({required String endpoint, required String model}) { + aiEndpoint = endpoint.trim(); + aiModel = model.trim(); + notifyListeners(); + } + + GeneratedLesson? get cachedAdaptiveLesson { + final raw = cachedAdaptiveLessonRaw; + if (raw == null) return null; + try { + final data = jsonDecode(raw) as Map; + final targets = data['targetItemIds'] as List?; + final target = targets?.singleOrNull; + final lesson = target is String + ? decodeGeneratedLesson(raw, expectedTargetItemId: target) + : null; + return lesson != null && + !reportedAdaptiveLessonIds.contains(lesson.lessonId) + ? lesson + : null; + } catch (_) { + return null; + } + } + + /// Call only after an independent AI audit approves the lesson. The audit + /// metadata stays beside the immutable lesson revision for later tracing. + void cacheApprovedAdaptiveLesson( + GeneratedLesson lesson, { + DateTime? auditedAt, + String? auditor, + }) { + cachedAdaptiveLessonRaw = encodeGeneratedLesson(lesson); + cachedAdaptiveLessonAuditedAt = auditedAt ?? DateTime.now(); + cachedAdaptiveLessonAuditor = auditor; + adaptiveLessonDraftId = lesson.lessonId; + adaptiveLessonDraftIndex = 0; + adaptiveLessonDraftAnswer = ''; + adaptiveLessonDraftReferenceShown = false; + adaptiveLessonDraftUsedVoice = false; + adaptiveLessonDraftTranscriptEdited = false; + adaptiveLessonDraftTranscriptConfirmed = false; + adaptiveLessonDraftOriginalTranscript = ''; + adaptiveLessonDraftRecordingPath = null; + notifyListeners(); + } + + /// Stores progress after every input change so an interrupted AI teaching + /// activity can resume at exactly the same task. This is not mastery data. + void saveAdaptiveLessonDraft({ + required GeneratedLesson lesson, + required int taskIndex, + required String answer, + required bool referenceShown, + bool usedVoice = false, + bool transcriptEdited = false, + bool transcriptConfirmed = false, + String originalTranscript = '', + String? recordingPath, + }) { + final confirmedVoiceTranscript = + usedVoice && transcriptConfirmed && !transcriptEdited; + adaptiveLessonDraftId = lesson.lessonId; + adaptiveLessonDraftIndex = taskIndex; + adaptiveLessonDraftAnswer = answer; + adaptiveLessonDraftReferenceShown = referenceShown; + adaptiveLessonDraftUsedVoice = usedVoice; + adaptiveLessonDraftTranscriptEdited = transcriptEdited; + adaptiveLessonDraftTranscriptConfirmed = confirmedVoiceTranscript; + adaptiveLessonDraftOriginalTranscript = originalTranscript; + adaptiveLessonDraftRecordingPath = recordingPath; + notifyListeners(); + } + + void clearAdaptiveLessonDraft() { + adaptiveLessonDraftId = null; + adaptiveLessonDraftIndex = 0; + adaptiveLessonDraftAnswer = ''; + adaptiveLessonDraftReferenceShown = false; + adaptiveLessonDraftUsedVoice = false; + adaptiveLessonDraftTranscriptEdited = false; + adaptiveLessonDraftTranscriptConfirmed = false; + adaptiveLessonDraftOriginalTranscript = ''; + adaptiveLessonDraftRecordingPath = null; + notifyListeners(); + } + + /// A reported AI lesson must never be shown again from this local cache. + /// Existing submissions stay as pending/assisted learning history; they + /// never contributed to mastery and therefore need no mastery rollback. + void reportAdaptiveLesson(GeneratedLesson lesson) { + reportedAdaptiveLessonIds.add(lesson.lessonId); + if (cachedAdaptiveLesson?.lessonId == lesson.lessonId) { + cachedAdaptiveLessonRaw = null; + cachedAdaptiveLessonAuditedAt = null; + cachedAdaptiveLessonAuditor = null; + } + adaptiveLessonDraftId = null; + adaptiveLessonDraftIndex = 0; + adaptiveLessonDraftAnswer = ''; + adaptiveLessonDraftReferenceShown = false; + adaptiveLessonDraftUsedVoice = false; + adaptiveLessonDraftTranscriptEdited = false; + adaptiveLessonDraftTranscriptConfirmed = false; + adaptiveLessonDraftOriginalTranscript = ''; + adaptiveLessonDraftRecordingPath = null; + notifyListeners(); + } + + void toggleChineseHints(bool value) { + showChineseHints = value; + notifyListeners(); + } + + void toggleKeepRecordings(bool value) { + keepRecordings = value; + notifyListeners(); + } + + void clearProgress() { + completedLessons = 0; + activeLessonId = 'a0-01'; + completedLessonIds.clear(); + completedSegmentIds.clear(); + reportedAiVariantKeys.clear(); + activeSegmentIndexes.clear(); + reviewQueue.clear(); + attemptEvidence.clear(); + cachedAdaptiveLessonRaw = null; + cachedAdaptiveLessonAuditedAt = null; + cachedAdaptiveLessonAuditor = null; + adaptiveLessonDraftId = null; + adaptiveLessonDraftIndex = 0; + adaptiveLessonDraftAnswer = ''; + adaptiveLessonDraftReferenceShown = false; + adaptiveLessonDraftUsedVoice = false; + adaptiveLessonDraftTranscriptEdited = false; + adaptiveLessonDraftTranscriptConfirmed = false; + adaptiveLessonDraftOriginalTranscript = ''; + adaptiveLessonDraftRecordingPath = null; + reportedAdaptiveLessonIds.clear(); + assessments.clear(); + assessmentDraft = null; + dialogueDraft = null; + lessonStep = LessonStep.preview; + previewIndex = 0; + lessonListeningComplete = false; + lessonSpeakingComplete = false; + lessonReadingComplete = false; + lessonWritingComplete = false; + lessonDialogueComplete = false; + independentAttemptComplete = false; + independentAttemptAssisted = false; + independentAttemptSpoken = false; + mastery.clear(); + notifyListeners(); + } + + void _recordEvidence(String id, EvidenceKind evidence) { + final existing = + mastery[id] ?? + MasteryItem( + id: id, + label: id, + status: MasteryStatus.newItem, + evidence: const [], + ); + final allEvidence = [...existing.evidence, evidence]; + MasteryStatus next = existing.status; + if (evidence == EvidenceKind.independentSuccess) { + next = switch (existing.status) { + MasteryStatus.newItem => MasteryStatus.recognize, + MasteryStatus.recognize => MasteryStatus.recall, + MasteryStatus.recall => MasteryStatus.use, + MasteryStatus.use || MasteryStatus.master => existing.status, + MasteryStatus.needsReview => MasteryStatus.recall, + }; + } + mastery[id] = existing.copyWith(status: next, evidence: allEvidence); + } + + void _recordReviewSuccess(String id, int successes) { + final existing = + mastery[id] ?? + MasteryItem( + id: id, + label: a0CoreItems[id] ?? id, + status: MasteryStatus.newItem, + evidence: const [], + ); + final checkpoint = successes.clamp(0, 4).toInt(); + mastery[id] = existing.copyWith( + status: _statusForCheckpoint(checkpoint), + checkpoint: checkpoint, + needsReview: false, + evidence: [...existing.evidence, EvidenceKind.independentSuccess], + ); + } + + MasteryStatus _statusForCheckpoint(int checkpoint) => switch (checkpoint) { + 0 => MasteryStatus.newItem, + 1 => MasteryStatus.recognize, + 2 => MasteryStatus.recall, + 3 => MasteryStatus.use, + _ => MasteryStatus.master, + }; + + List get _activeTargetItemIds { + final lesson = lessonById(activeLessonId); + return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds; + } + + String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02'; + + /// Records one evidence row for every target actually attached to a local + /// task. A task can be displayed once but must never silently award its + /// result to unrelated core items. + void _recordLessonTaskEvidence({ + required List targetIds, + required String taskSuffix, + required String skill, + required EvidenceKind outcome, + String? rawAnswer, + String inputMode = 'text', + String? recordingPath, + bool assisted = false, + }) { + final now = DateTime.now(); + for (var index = 0; index < targetIds.length; index++) { + final id = targetIds[index]; + _recordEvidence(id, outcome); + attemptEvidence.add( + AttemptEvidence( + id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index', + itemId: id, + taskId: 'lesson-$_activeSegmentId-$taskSuffix', + skill: skill, + inputMode: inputMode, + outcome: outcome, + createdAt: now, + rawAnswer: rawAnswer, + recordingPath: recordingPath, + assisted: assisted, + ), + ); + } + } + + void _introduceLessonTargets() { + _recordLessonTaskEvidence( + targetIds: _activeTargetItemIds, + taskSuffix: 'listening', + skill: '听力输入', + outcome: EvidenceKind.exposure, + ); + for (final id in _activeTargetItemIds) { + final introduced = mastery[id]!; + if (introduced.firstTaughtAt == null) { + mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now()); + } + if (reviewQueue.any((item) => item.id == id)) continue; + final template = coreReviewTemplate(id); + reviewQueue.add( + ReviewItem( + id: id, + target: a0CoreItems[id] ?? id, + prompt: template.prompt, + hint: template.hint, + dueAt: DateTime.now().add(const Duration(days: 1)), + skill: template.skill, + ), + ); + } + } +} diff --git a/kouyu_english/lib/core/app_theme.dart b/kouyu_english/lib/core/app_theme.dart new file mode 100644 index 0000000..c446cf3 --- /dev/null +++ b/kouyu_english/lib/core/app_theme.dart @@ -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), + ), + ); +} diff --git a/kouyu_english/lib/core/assessment_bank.dart b/kouyu_english/lib/core/assessment_bank.dart new file mode 100644 index 0000000..07c31d6 --- /dev/null +++ b/kouyu_english/lib/core/assessment_bank.dart @@ -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 choices; + final int? answerIndex; + final List requiredWords; + final String abilityId; +} + +class AssessmentPack { + const AssessmentPack({required this.id, required this.tasks}); + final String id; + final List tasks; + List forSkill(AssessmentSkill skill) => + tasks.where((task) => task.skill == skill).toList(); +} + +final a0AssessmentPacks = [ + AssessmentPack( + id: 'A0-E1', + tasks: _tasks( + 'A', + const [ + 'Hello. I’m Mia.|自我介绍|买东西|问时间|0', + 'How do you spell your name?|问姓名拼写|问地点|问时间|0', + 'I’m tired today.|很好|累|来自哪里|1', + 'seven|6|7|8|1', + 'one-three-eight|138|183|318|0', + 'It’s a key.|书|钥匙|包|1', + 'I’m from Hong Kong.|香港|伦敦|北京|0', + 'This is my mother.|妈妈|姐姐|朋友|0', + 'It is Monday.|星期一|星期二|星期三|0', + 'I like tea.|茶|咖啡|电影|0', + ], + const [ + 'Mia: I’m from Hong Kong.\nShen: I’m good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0', + 'Mia: It is Monday.\nShen: It is three o’clock.|几点?|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: What’s this?\nShen: It’s 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', + 'I’m okay.|很好|还可以|累|1', + 'four|3|4|5|1', + 'two-nine-six|296|269|926|0', + 'It’s a bag.|笔|包|水|1', + 'I’m from London.|伦敦|香港|北京|0', + 'This is my sister.|姐姐|妈妈|朋友|0', + 'It is Friday.|星期五|星期一|星期日|0', + 'I like coffee.|茶|咖啡|音乐|1', + ], + const [ + 'Mia: I’m from London.\nLeo: I’m okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0', + 'Mia: It is Friday.\nLeo: It is six o’clock.|几点?|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: What’s this?\nLeo: It’s 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 o’clock': 'six o’clock', + '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 o’clock': 'two o’clock', + '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 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 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 _tasks( + String version, + List listening, + List reading, + List writing, + List 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), + ), + ]; +} diff --git a/kouyu_english/lib/core/generated_content.dart b/kouyu_english/lib/core/generated_content.dart new file mode 100644 index 0000000..f05ee5e --- /dev/null +++ b/kouyu_english/lib/core/generated_content.dart @@ -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 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> requiredAnyPhrases; + final List acceptedAnswers; + final List 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 abilityIds; + final List prerequisiteIds; + final List targetItemIds; + final List receptiveChunks; + final List previewItemIds; + final int estimatedMinutes; + final List 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 || 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) 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 = []; + const skills = {'listening', 'speaking', 'reading', 'writing'}; + const types = {'listenChoice', 'repeat', 'readAnswer', 'writeAnswer'}; + for (final rawTask in rawTasks) { + if (rawTask is! Map) 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 _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 || 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 = >[]; + 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? _stringList(Object? value, {required int max}) { + if (value is! List || value.length > max) return null; + final result = []; + 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 || 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 = []; + 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; + } +} diff --git a/kouyu_english/lib/core/local_store.dart b/kouyu_english/lib/core/local_store.dart new file mode 100644 index 0000000..af0a3d5 --- /dev/null +++ b/kouyu_english/lib/core/local_store.dart @@ -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? _opening; + Future? _initializing; + + Future _open() => _opening ??= _create(); + + Future _create() async { + final directory = await getApplicationSupportDirectory(); + final database = NativeDatabase.createInBackground( + File('${directory.path}/kouyu_learning.sqlite'), + ); + _executor = database; + await _ensureSchema(database); + return database; + } + + Future _ensureSchema(QueryExecutor executor) => + _initializing ??= _openAndCreateSchema(executor); + + Future _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 _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 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?> _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 = { + ..._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) => { + '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) => { + '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 _decodeObject(Object? raw) { + if (raw is! String) return const {}; + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : const {}; + } + + Future 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 _writeEntities(QueryExecutor executor, String payload) async { + final data = jsonDecode(payload) as Map; + 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 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 beforeOpen( + QueryExecutor executor, + OpeningDetails details, + ) async {} +} diff --git a/kouyu_english/lib/core/models.dart b/kouyu_english/lib/core/models.dart new file mode 100644 index 0000000..8ea392f --- /dev/null +++ b/kouyu_english/lib/core/models.dart @@ -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 slots; + final List 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 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 results; + final Set pendingSkills; + + bool get passed => + AssessmentSkill.values.every((skill) => results[skill] == true); + + List 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 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 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 evidence; + final bool needsReview; + final int checkpoint; + final DateTime? firstTaughtAt; + + MasteryItem copyWith({ + MasteryStatus? status, + List? 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 completedTasks; + final String personalSentence; + final bool usedHelp; +} diff --git a/kouyu_english/lib/core/review_feedback.dart b/kouyu_english/lib/core/review_feedback.dart new file mode 100644 index 0000000..80fef73 --- /dev/null +++ b/kouyu_english/lib/core/review_feedback.dart @@ -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 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' => '用 I’m / I am 或 My name is 介绍一个名字。', + 'A0-P02' => '试着问:What’s 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 … 说一项喜好。', + _ => '再补充本次目标中的关键英文词或句型。', + }; +} diff --git a/kouyu_english/lib/core/seed_courses.dart b/kouyu_english/lib/core/seed_courses.dart new file mode 100644 index 0000000..823f905 --- /dev/null +++ b/kouyu_english/lib/core/seed_courses.dart @@ -0,0 +1,1249 @@ +import 'models.dart'; + +/// Stable A0 core identities taught by each seed lesson. Wording can vary in +/// future exercises without creating a different mastery item. +const a0TargetItemIdsByLesson = >{ + 'a0-01': ['A0-W36', 'A0-W37', 'A0-W40', 'A0-P01', 'A0-P02', 'A0-P03'], + 'a0-02': ['A0-P04'], + 'a0-03': ['A0-W33', 'A0-W34', 'A0-W35', 'A0-P05', 'A0-P06'], + 'a0-04': [ + 'A0-W01', + 'A0-W02', + 'A0-W03', + 'A0-W04', + 'A0-W05', + 'A0-W06', + 'A0-W07', + 'A0-W08', + 'A0-W09', + 'A0-W10', + 'A0-W11', + 'A0-W20', + 'A0-P07', + 'A0-P08', + ], + 'a0-05': ['A0-W19', 'A0-W21', 'A0-W22', 'A0-W23', 'A0-P09', 'A0-P10'], + 'a0-06': ['A0-P11', 'A0-P12'], + 'a0-07': ['A0-W25', 'A0-W26', 'A0-W27', 'A0-W28', 'A0-P13'], + 'a0-08': [ + 'A0-W12', + 'A0-W13', + 'A0-W14', + 'A0-W15', + 'A0-W16', + 'A0-W17', + 'A0-W18', + 'A0-P14', + 'A0-P15', + 'A0-P16', + 'A0-P17', + ], + 'a0-09': [ + 'A0-W29', + 'A0-W30', + 'A0-W31', + 'A0-W32', + 'A0-W38', + 'A0-W39', + 'A0-P18', + 'A0-P19', + ], + 'a0-10': ['A0-W24', 'A0-P20'], +}; + +/// A topic may contain multiple independently resumable teaching segments. +/// Segment IDs are stable even if later wording or exercises change. +class LessonSegment { + const LessonSegment({ + required this.id, + required this.targetItemIds, + required this.previewItemIds, + this.dialogueRequiredTerms = const [], + this.independentRequiredTerms = const [], + this.estimatedMinutes = 12, + }); + + final String id; + final List targetItemIds; + final List previewItemIds; + + /// One accepted term group per controlled dialogue turn. A learner only + /// needs to use one term from the current group; the surrounding wording + /// may vary naturally. + final List> dialogueRequiredTerms; + + /// Terms that show the independent attempt contains the taught content. + final List independentRequiredTerms; + final int estimatedMinutes; +} + +const a0LessonSegments = >{ + 'a0-04': [ + LessonSegment( + id: 'a0-04-a', + targetItemIds: [ + 'A0-W01', + 'A0-W02', + 'A0-W03', + 'A0-W04', + 'A0-W05', + 'A0-W06', + ], + previewItemIds: [ + 'A0-W01', + 'A0-W02', + 'A0-W03', + 'A0-W04', + 'A0-W05', + 'A0-W06', + ], + dialogueRequiredTerms: [ + ['zero', 'one', 'two'], + ['three', 'four', 'five'], + ], + independentRequiredTerms: ['zero', 'one', 'two', 'three', 'four', 'five'], + ), + LessonSegment( + id: 'a0-04-b', + targetItemIds: [ + 'A0-W07', + 'A0-W08', + 'A0-W09', + 'A0-W10', + 'A0-W11', + 'A0-W20', + ], + previewItemIds: [ + 'A0-W07', + 'A0-W08', + 'A0-W09', + 'A0-W10', + 'A0-W11', + 'A0-W20', + ], + dialogueRequiredTerms: [ + ['six', 'seven', 'eight'], + ['it is a phone', "it's a phone"], + ], + independentRequiredTerms: ['six', 'seven', 'eight', 'nine', 'ten'], + ), + LessonSegment( + id: 'a0-04-c', + targetItemIds: ['A0-P07', 'A0-P08'], + previewItemIds: ['A0-P07', 'A0-P08'], + dialogueRequiredTerms: [ + [ + 'my number is', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'zero', + ], + [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'zero', + ], + ], + independentRequiredTerms: [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'zero', + ], + ), + ], + 'a0-08': [ + LessonSegment( + id: 'a0-08-a', + targetItemIds: ['A0-W12', 'A0-W13', 'A0-W14', 'A0-P14', 'A0-P15'], + previewItemIds: ['A0-W12', 'A0-W13', 'A0-W14', 'A0-P14', 'A0-P15'], + dialogueRequiredTerms: [ + ['what day'], + ['monday', 'tuesday', 'wednesday'], + ], + independentRequiredTerms: ['monday', 'tuesday', 'wednesday'], + ), + LessonSegment( + id: 'a0-08-b', + targetItemIds: ['A0-W15', 'A0-W16', 'A0-W17', 'A0-W18'], + previewItemIds: ['A0-W15', 'A0-W16', 'A0-W17', 'A0-W18'], + dialogueRequiredTerms: [ + ['what day'], + ['thursday', 'friday', 'saturday', 'sunday'], + ], + independentRequiredTerms: ['thursday', 'friday', 'saturday', 'sunday'], + ), + LessonSegment( + id: 'a0-08-c', + targetItemIds: ['A0-P16', 'A0-P17'], + previewItemIds: ['A0-P16', 'A0-P17'], + dialogueRequiredTerms: [ + ['what time'], + ["o'clock", 'oclock'], + ], + independentRequiredTerms: ["o'clock", 'oclock'], + ), + ], +}; + +class SeedLesson { + const SeedLesson({ + required this.id, + required this.number, + required this.title, + required this.outcome, + required this.vocabulary, + this.schemaVersion = '1.0', + this.revision = 1, + this.stageVersion = 'A0-1.0', + this.source = ContentSource.builtInOriginal, + this.status = ContentStatus.approved, + this.estimatedMinutes = 12, + }); + final String id; + final int number; + final String title; + final String outcome; + final List vocabulary; + final String schemaVersion; + final int revision; + final String stageVersion; + final ContentSource source; + final ContentStatus status; + final int estimatedMinutes; + + List get targetItemIds => + a0TargetItemIdsByLesson[id] ?? const []; + List get abilityIds => ['A0-C${number.toString().padLeft(2, '0')}']; + List get prerequisiteIds => number == 1 + ? const [] + : ['a0-${(number - 1).toString().padLeft(2, '0')}']; + List get segments => + a0LessonSegments[id] ?? + [ + LessonSegment( + id: '$id-a', + targetItemIds: targetItemIds, + previewItemIds: targetItemIds, + estimatedMinutes: estimatedMinutes, + ), + ]; +} + +const a0SeedLessons = [ + SeedLesson( + id: 'a0-01', + number: 1, + title: '你好,我是……', + outcome: '介绍姓名并回应问候', + vocabulary: [ + VocabularyItem( + id: 'a0-01-hello', + word: 'Hello', + meaning: '你好', + example: 'Hello. I’m Mia.', + exampleMeaning: '你好。我是 Mia。', + ipa: '/həˈləʊ/', + ), + VocabularyItem( + id: 'a0-01-name', + word: 'name', + meaning: '名字', + example: 'My name is Shen.', + exampleMeaning: '我的名字是 Shen。', + ipa: '/neɪm/', + ), + VocabularyItem( + id: 'a0-01-nice', + word: 'Nice to meet you', + meaning: '很高兴认识你', + example: 'Nice to meet you, too.', + exampleMeaning: '我也很高兴认识你。', + ), + ], + ), + SeedLesson( + id: 'a0-02', + number: 2, + title: '拼读我的名字', + outcome: '听懂拼写,并说出名字字母', + vocabulary: [ + VocabularyItem( + id: 'a0-02-spell', + word: 'spell', + meaning: '拼写', + example: 'How do you spell that?', + exampleMeaning: '那个怎么拼?', + ipa: '/spel/', + ), + VocabularyItem( + id: 'a0-02-name', + word: 'name', + meaning: '名字', + example: 'My name is Shen.', + exampleMeaning: '我的名字是 Shen。', + ipa: '/neɪm/', + ), + VocabularyItem( + id: 'a0-02-question', + word: 'How do you spell that?', + meaning: '那个怎么拼?', + example: 'How do you spell your name?', + exampleMeaning: '你的名字怎么拼?', + ), + ], + ), + SeedLesson( + id: 'a0-03', + number: 3, + title: '你好吗?', + outcome: '问候并表达简单状态', + vocabulary: [ + VocabularyItem( + id: 'a0-03-good', + word: 'good', + meaning: '好的', + example: 'I’m good, thanks.', + exampleMeaning: '我很好,谢谢。', + ipa: '/ɡʊd/', + ), + VocabularyItem( + id: 'a0-03-tired', + word: 'tired', + meaning: '累的', + example: 'I’m tired today.', + exampleMeaning: '我今天有点累。', + ipa: '/ˈtaɪəd/', + ), + VocabularyItem( + id: 'a0-03-how', + word: 'How are you?', + meaning: '你好吗?', + example: 'How are you today?', + exampleMeaning: '你今天怎么样?', + ), + ], + ), + SeedLesson( + id: 'a0-04', + number: 4, + title: '数字与电话号码', + outcome: '说 0–10 与虚拟号码', + vocabulary: [ + VocabularyItem( + id: 'a0-04-three', + word: 'three', + meaning: '三', + example: 'One, three, eight.', + exampleMeaning: '一、三、八。', + ipa: '/θriː/', + ), + VocabularyItem( + id: 'a0-04-number', + word: 'number', + meaning: '号码', + example: 'My number is 1-3-8.', + exampleMeaning: '我的号码是 138。', + ipa: '/ˈnʌmbə(r)/', + ), + VocabularyItem( + id: 'a0-04-phone', + word: 'phone', + meaning: '电话', + example: 'What’s your phone number?', + exampleMeaning: '你的电话号码是多少?', + ), + ], + ), + SeedLesson( + id: 'a0-05', + number: 5, + title: '这是什么?', + outcome: '询问并说出常见物品', + vocabulary: [ + VocabularyItem( + id: 'a0-05-book', + word: 'book', + meaning: '书', + example: 'It’s a book.', + exampleMeaning: '这是一本书。', + ipa: '/bʊk/', + ), + VocabularyItem( + id: 'a0-05-pen', + word: 'pen', + meaning: '笔', + example: 'It’s a pen.', + exampleMeaning: '这是一支笔。', + ipa: '/pen/', + ), + VocabularyItem( + id: 'a0-05-this', + word: 'What’s this?', + meaning: '这是什么?', + example: 'What’s this? It’s a key.', + exampleMeaning: '这是什么?这是钥匙。', + ), + ], + ), + SeedLesson( + id: 'a0-06', + number: 6, + title: '我来自哪里?', + outcome: '说明来自哪里并反问', + vocabulary: [ + VocabularyItem( + id: 'a0-06-from', + word: 'from', + meaning: '来自', + example: 'I’m from Hong Kong.', + exampleMeaning: '我来自香港。', + ipa: '/frəm/', + ), + VocabularyItem( + id: 'a0-06-place', + word: 'place', + meaning: '地点', + example: 'Where are you from?', + exampleMeaning: '你来自哪里?', + ), + ], + ), + SeedLesson( + id: 'a0-07', + number: 7, + title: '我的家人', + outcome: '介绍一位家人', + vocabulary: [ + VocabularyItem( + id: 'a0-07-family', + word: 'family', + meaning: '家人', + example: 'This is my family.', + exampleMeaning: '这是我的家人。', + ipa: '/ˈfæməli/', + ), + VocabularyItem( + id: 'a0-07-mother', + word: 'mother', + meaning: '母亲', + example: 'This is my mother.', + exampleMeaning: '这是我的妈妈。', + ), + ], + ), + SeedLesson( + id: 'a0-08', + number: 8, + title: '星期与时间', + outcome: '说今天和整点', + vocabulary: [ + VocabularyItem( + id: 'a0-08-monday', + word: 'Monday', + meaning: '星期一', + example: 'It is Monday.', + exampleMeaning: '今天是星期一。', + ), + VocabularyItem( + id: 'a0-08-oclock', + word: 'o’clock', + meaning: '整点', + example: 'It’s three o’clock.', + exampleMeaning: '现在三点整。', + ), + ], + ), + SeedLesson( + id: 'a0-09', + number: 9, + title: '我喜欢……', + outcome: '表达喜好并回答', + vocabulary: [ + VocabularyItem( + id: 'a0-09-like', + word: 'like', + meaning: '喜欢', + example: 'I like tea.', + exampleMeaning: '我喜欢茶。', + ipa: '/laɪk/', + ), + VocabularyItem( + id: 'a0-09-tea', + word: 'tea', + meaning: '茶', + example: 'Do you like tea?', + exampleMeaning: '你喜欢茶吗?', + ), + ], + ), + SeedLesson( + id: 'a0-10', + number: 10, + title: 'A0 综合任务', + outcome: '完成自我介绍、喜好和反问', + vocabulary: [ + VocabularyItem( + id: 'a0-10-again', + word: 'Please say that again.', + meaning: '请再说一遍。', + example: 'Please say that again.', + exampleMeaning: '请再说一遍。', + ), + VocabularyItem( + id: 'a0-10-slowly', + word: 'Please speak slowly.', + meaning: '请说慢一点。', + example: 'Please speak slowly.', + exampleMeaning: '请说慢一点。', + ), + ], + ), +]; + +SeedLesson lessonById(String id) => + a0SeedLessons.firstWhere((lesson) => lesson.id == id); + +class LessonActivity { + const LessonActivity({ + required this.listening, + required this.listeningQuestion, + required this.answers, + required this.speaking, + required this.reading, + required this.readingQuestion, + required this.readingAnswer, + required this.writingPrompt, + required this.writingExample, + required this.independentPrompt, + required this.independentHelp, + }); + final String listening; + final String listeningQuestion; + final List answers; + final String speaking; + final String reading; + final String readingQuestion; + final String readingAnswer; + final String writingPrompt; + final String writingExample; + final String independentPrompt; + final String independentHelp; +} + +const a0Activities = { + 'a0-01': LessonActivity( + listening: 'Hello. I’m Mia. Nice to meet you.', + listeningQuestion: 'Mia 在做什么?', + answers: ['自我介绍', '买东西', '问时间'], + speaking: 'Hello. I’m Shen. Nice to meet you.', + reading: + 'Mia: Hello. I’m Mia.\nShen: Hi. I’m Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.', + readingQuestion: '谁叫 Mia?', + readingAnswer: '第一位说话的人', + writingPrompt: '写一句问候和姓名介绍。', + writingExample: 'Hello. I’m Shen.', + independentPrompt: '不看句框,介绍你的名字并回应“Nice to meet you”。', + independentHelp: 'Hi. I’m … / Nice to meet you, too.', + ), + 'a0-02': LessonActivity( + listening: 'How do you spell your name?', + listeningQuestion: '这句话是什么意思?', + answers: ['你的名字怎么拼?', '你的名字是什么?', '你来自哪里?'], + speaking: 'S H E N', + reading: + 'Mia: What’s your name?\nShen: Shen.\nMia: How do you spell that?\nShen: S-H-E-N.', + readingQuestion: 'Shen 的名字怎么拼?', + readingAnswer: 'S-H-E-N', + writingPrompt: '把名字和拼写写完整。', + writingExample: 'My name is Shen.\nS-H-E-N.', + independentPrompt: '不看句框,用英文介绍名字并拼读它。', + independentHelp: 'My name is … / A-B-C.', + ), + 'a0-03': LessonActivity( + listening: 'How are you today?', + listeningQuestion: '对方在问什么?', + answers: ['你的状态', '你的号码', '你的地点'], + speaking: 'How are you? I’m good, thanks.', + reading: + 'Mia: Hi, Shen. How are you?\nShen: I’m good, thanks. How are you?\nMia: I’m okay.', + readingQuestion: 'Mia 状态如何?', + readingAnswer: 'okay', + writingPrompt: '写一句今天的状态。', + writingExample: 'I’m okay today.', + independentPrompt: '不看句框,问候并说出你的状态。', + independentHelp: 'How are you? / I’m …', + ), + 'a0-04': LessonActivity( + listening: 'One, three, eight.', + listeningQuestion: '你听到的是哪个号码?', + answers: ['138', '183', '318'], + speaking: 'My number is one-three-eight.', + reading: + 'Mia: What’s your phone number?\nShen: One-three-eight.\nMia: Thank you.', + readingQuestion: 'Shen 的号码是?', + readingAnswer: '138', + writingPrompt: '把虚拟号码 139 写成英文。', + writingExample: 'one-three-nine', + independentPrompt: '不看句框,说出一个三位虚拟号码。', + independentHelp: 'My number is one-two-three.', + ), + 'a0-05': LessonActivity( + listening: 'What’s this? It’s a key.', + listeningQuestion: '这是什么?', + answers: ['钥匙', '书', '水'], + speaking: 'What’s this? It’s a pen.', + reading: 'Mia: What’s this?\nShen: It’s a book.', + readingQuestion: '这是什么?', + readingAnswer: 'a book', + writingPrompt: '写一句“这是一支笔”。', + writingExample: 'It’s a pen.', + independentPrompt: '不看句框,说出一个身边物品。', + independentHelp: 'It’s a …', + ), + 'a0-06': LessonActivity( + listening: 'Where are you from?', + listeningQuestion: '对方在问什么?', + answers: ['来自哪里', '叫什么', '喜欢什么'], + speaking: 'I’m from Hong Kong.', + reading: 'Mia: Where are you from?\nShen: I’m from Hong Kong.', + readingQuestion: 'Shen 来自哪里?', + readingAnswer: 'Hong Kong', + writingPrompt: '写一句你来自哪里。', + writingExample: 'I’m from Hong Kong.', + independentPrompt: '不看句框,说来自哪里并反问对方。', + independentHelp: 'I’m from … / Where are you from?', + ), + 'a0-07': LessonActivity( + listening: 'Who is this? This is my mother.', + listeningQuestion: '这个人是谁?', + answers: ['妈妈', '朋友', '老师'], + speaking: 'This is my family.', + reading: 'Mia: Who is this?\nShen: This is my mother.', + readingQuestion: 'Shen 在介绍谁?', + readingAnswer: 'my mother', + writingPrompt: '介绍一位家人或朋友。', + writingExample: 'This is my mother.', + independentPrompt: '不看句框,介绍一位家人或朋友。', + independentHelp: 'This is my …', + ), + 'a0-08': LessonActivity( + listening: 'It’s three o’clock on Monday.', + listeningQuestion: '是什么时间?', + answers: ['星期一三点', '星期三一点', '星期一一点'], + speaking: 'It’s three o’clock.', + reading: 'Mia: What day is it?\nShen: It’s Monday.', + readingQuestion: '今天星期几?', + readingAnswer: 'Monday', + writingPrompt: '写一句今天和整点。', + writingExample: 'It’s Monday. It’s three o’clock.', + independentPrompt: '不看句框,说今天星期几或现在几点。', + independentHelp: 'It’s … / It’s … o’clock.', + ), + 'a0-09': LessonActivity( + listening: 'Do you like tea? Yes, I do.', + listeningQuestion: '对方喜欢茶吗?', + answers: ['喜欢', '不喜欢', '不知道'], + speaking: 'I like tea. Do you like tea?', + reading: 'Mia: I like tea. Do you like tea?\nShen: Yes, I do.', + readingQuestion: 'Shen 的回答是什么?', + readingAnswer: 'Yes, I do.', + writingPrompt: '写一句你的喜好。', + writingExample: 'I like tea.', + independentPrompt: '不看句框,说一个喜好并反问。', + independentHelp: 'I like … / Do you like …?', + ), + 'a0-10': LessonActivity( + listening: 'Please say that again.', + listeningQuestion: '对方希望什么?', + answers: ['再说一遍', '说慢一点', '写下来'], + speaking: 'Please speak slowly.', + reading: 'Mia: Please say that again.\nShen: Please speak slowly.', + readingQuestion: 'Shen 希望什么?', + readingAnswer: 'speak slowly', + writingPrompt: '写姓名、地点和喜好三句。', + writingExample: 'I’m Shen.\nI’m from Hong Kong.\nI like tea.', + independentPrompt: '不看帮助,完成姓名、地点、喜好和反问。', + independentHelp: 'I’m … / I’m from … / I like …', + ), +}; + +LessonActivity activityByLessonId(String id) => a0Activities[id]!; + +/// Reviewed activities for split topics. They intentionally avoid values from +/// later segments so a segment can be completed independently. +const a0SegmentActivities = { + 'a0-04-a': LessonActivity( + listening: 'One, two, three.', + listeningQuestion: '听到哪一组数字?', + answers: ['123', '132', '213'], + speaking: 'Zero, one, two, three.', + reading: 'Mia: One, two, three.\nShen: Four, five.', + readingQuestion: 'Shen 说了哪两个数字?', + readingAnswer: 'four, five', + writingPrompt: '把 0、1、2、3 写成英文。', + writingExample: 'zero, one, two, three', + independentPrompt: '不看帮助,说出三个 0 到 5 的数字。', + independentHelp: 'zero / one / two / three / four / five', + ), + 'a0-04-b': LessonActivity( + listening: 'Six, seven, eight.', + listeningQuestion: '听到哪一组数字?', + answers: ['678', '687', '768'], + speaking: 'My phone is here.', + reading: 'Mia: What is this?\nShen: It is a phone.', + readingQuestion: '这是什么?', + readingAnswer: 'a phone', + writingPrompt: '把 6、7、8 写成英文。', + writingExample: 'six, seven, eight', + independentPrompt: '不看帮助,说出三个 6 到 10 的数字。', + independentHelp: 'six / seven / eight / nine / ten', + ), + 'a0-04-c': LessonActivity( + listening: 'My number is one-three-eight.', + listeningQuestion: '号码是多少?', + answers: ['138', '183', '318'], + speaking: 'My number is one-three-eight.', + reading: 'Mia: What’s your phone number?\nShen: One-three-eight.', + readingQuestion: 'Shen 的号码是?', + readingAnswer: '138', + writingPrompt: '把虚拟号码 139 写成英文。', + writingExample: 'one-three-nine', + independentPrompt: '不看句框,说出一个三位虚拟号码。', + independentHelp: 'My number is one-two-three.', + ), + 'a0-08-a': LessonActivity( + listening: 'It is Monday.', + listeningQuestion: '今天星期几?', + answers: ['星期一', '星期二', '星期三'], + speaking: 'It is Monday.', + reading: 'Mia: What day is it?\nShen: It is Tuesday.', + readingQuestion: '今天星期几?', + readingAnswer: 'Tuesday', + writingPrompt: '写一句今天是星期一、二或三。', + writingExample: 'It is Monday.', + independentPrompt: '不看帮助,说出星期一、二或三。', + independentHelp: 'It is Monday / Tuesday / Wednesday.', + ), + 'a0-08-b': LessonActivity( + listening: 'It is Friday.', + listeningQuestion: '今天星期几?', + answers: ['星期五', '星期四', '星期日'], + speaking: 'It is Friday.', + reading: 'Mia: What day is it?\nShen: It is Sunday.', + readingQuestion: '今天星期几?', + readingAnswer: 'Sunday', + writingPrompt: '写一句今天是星期四到日。', + writingExample: 'It is Friday.', + independentPrompt: '不看帮助,说出星期四到日。', + independentHelp: 'It is Thursday / Friday / Saturday / Sunday.', + ), + 'a0-08-c': LessonActivity( + listening: 'It is three o’clock.', + listeningQuestion: '是什么时间?', + answers: ['三点', '一点', '星期三'], + speaking: 'It is three o’clock.', + reading: 'Mia: What time is it?\nShen: It is three o’clock.', + readingQuestion: '几点?', + readingAnswer: 'three o’clock', + writingPrompt: '写一句整点时间。', + writingExample: 'It is three o’clock.', + independentPrompt: '不看帮助,说一个整点时间。', + independentHelp: 'It is … o’clock.', + ), +}; + +LessonActivity activityBySegmentId(String segmentId, String lessonId) => + a0SegmentActivities[segmentId] ?? activityByLessonId(lessonId); + +/// Short, reviewed entries used before each split segment. These are kept +/// separate from review prompts: a review hint is not a learner-facing +/// dictionary definition. +const a0SegmentVocabulary = >{ + 'a0-04-a': [ + VocabularyItem( + id: 'A0-W01', + word: 'zero', + meaning: '零', + example: 'Zero, one, two.', + exampleMeaning: '零、一、二。', + ipa: '/ˈzɪrəʊ/', + ), + VocabularyItem( + id: 'A0-W02', + word: 'one', + meaning: '一', + example: 'One, two, three.', + exampleMeaning: '一、二、三。', + ipa: '/wʌn/', + ), + VocabularyItem( + id: 'A0-W03', + word: 'two', + meaning: '二', + example: 'Two and three.', + exampleMeaning: '二和三。', + ipa: '/tuː/', + ), + VocabularyItem( + id: 'A0-W04', + word: 'three', + meaning: '三', + example: 'Three, four, five.', + exampleMeaning: '三、四、五。', + ipa: '/θriː/', + ), + VocabularyItem( + id: 'A0-W05', + word: 'four', + meaning: '四', + example: 'Four and five.', + exampleMeaning: '四和五。', + ipa: '/fɔː/', + ), + VocabularyItem( + id: 'A0-W06', + word: 'five', + meaning: '五', + example: 'Five, please.', + exampleMeaning: '五,请。', + ipa: '/faɪv/', + ), + ], + 'a0-04-b': [ + VocabularyItem( + id: 'A0-W07', + word: 'six', + meaning: '六', + example: 'Six, seven, eight.', + exampleMeaning: '六、七、八。', + ipa: '/sɪks/', + ), + VocabularyItem( + id: 'A0-W08', + word: 'seven', + meaning: '七', + example: 'Seven, eight, nine.', + exampleMeaning: '七、八、九。', + ipa: '/ˈsevən/', + ), + VocabularyItem( + id: 'A0-W09', + word: 'eight', + meaning: '八', + example: 'Eight, nine, ten.', + exampleMeaning: '八、九、十。', + ipa: '/eɪt/', + ), + VocabularyItem( + id: 'A0-W10', + word: 'nine', + meaning: '九', + example: 'Nine and ten.', + exampleMeaning: '九和十。', + ipa: '/naɪn/', + ), + VocabularyItem( + id: 'A0-W11', + word: 'ten', + meaning: '十', + example: 'Ten, please.', + exampleMeaning: '十,请。', + ipa: '/ten/', + ), + VocabularyItem( + id: 'A0-W20', + word: 'phone', + meaning: '电话;手机', + example: 'It is a phone.', + exampleMeaning: '它是一部手机。', + ipa: '/fəʊn/', + ), + ], + 'a0-04-c': [ + VocabularyItem( + id: 'A0-P07', + word: "What's your phone number?", + meaning: '你的电话号码是多少?', + example: "What's your phone number?", + exampleMeaning: '你的电话号码是多少?', + ), + VocabularyItem( + id: 'A0-P08', + word: 'My number is one-three-eight.', + meaning: '我的号码是 138。', + example: 'My number is one-three-eight.', + exampleMeaning: '我的号码是 138。', + ), + ], + 'a0-08-a': [ + VocabularyItem( + id: 'A0-W12', + word: 'Monday', + meaning: '星期一', + example: 'It is Monday.', + exampleMeaning: '今天是星期一。', + ipa: '/ˈmʌndeɪ/', + ), + VocabularyItem( + id: 'A0-W13', + word: 'Tuesday', + meaning: '星期二', + example: 'It is Tuesday.', + exampleMeaning: '今天是星期二。', + ipa: '/ˈtjuːzdeɪ/', + ), + VocabularyItem( + id: 'A0-W14', + word: 'Wednesday', + meaning: '星期三', + example: 'It is Wednesday.', + exampleMeaning: '今天是星期三。', + ipa: '/ˈwenzdeɪ/', + ), + VocabularyItem( + id: 'A0-P14', + word: 'What day is it today?', + meaning: '今天星期几?', + example: 'What day is it today?', + exampleMeaning: '今天星期几?', + ), + VocabularyItem( + id: 'A0-P15', + word: "It's Monday.", + meaning: '今天是星期一。', + example: "It's Monday.", + exampleMeaning: '今天是星期一。', + ), + ], + 'a0-08-b': [ + VocabularyItem( + id: 'A0-W15', + word: 'Thursday', + meaning: '星期四', + example: 'It is Thursday.', + exampleMeaning: '今天是星期四。', + ipa: '/ˈθɜːzdeɪ/', + ), + VocabularyItem( + id: 'A0-W16', + word: 'Friday', + meaning: '星期五', + example: 'It is Friday.', + exampleMeaning: '今天是星期五。', + ipa: '/ˈfraɪdeɪ/', + ), + VocabularyItem( + id: 'A0-W17', + word: 'Saturday', + meaning: '星期六', + example: 'It is Saturday.', + exampleMeaning: '今天是星期六。', + ipa: '/ˈsætədeɪ/', + ), + VocabularyItem( + id: 'A0-W18', + word: 'Sunday', + meaning: '星期日', + example: 'It is Sunday.', + exampleMeaning: '今天是星期日。', + ipa: '/ˈsʌndeɪ/', + ), + ], + 'a0-08-c': [ + VocabularyItem( + id: 'A0-P16', + word: 'What time is it?', + meaning: '现在几点?', + example: 'What time is it?', + exampleMeaning: '现在几点?', + ), + VocabularyItem( + id: 'A0-P17', + word: "It's three o'clock.", + meaning: '现在三点。', + example: "It's three o'clock.", + exampleMeaning: '现在三点。', + ), + ], +}; + +List vocabularyBySegmentId(String segmentId, String lessonId) { + if (!a0SegmentActivities.containsKey(segmentId)) { + return lessonById(lessonId).vocabulary; + } + return a0SegmentVocabulary[segmentId] ?? const []; +} + +/// One practical rule per seed lesson. These are reminders for using a sentence +/// in context, not abstract grammar lectures. +const a0GrammarNotes = { + 'a0-01': '介绍自己时,把名字放在 I’m 后面:I’m Mia. 不需要先学复杂时态。', + 'a0-02': 'My name is 后面接名字;拼读时每个字母之间稍微停顿。', + 'a0-03': '说状态用 I’m + 状态:I’m good / okay / tired。', + 'a0-04': '报号码时数字一个一个说:one-three-eight,不要当成一百三十八。', + 'a0-05': '问物品用 What’s this?;回答用 It’s a + 物品。', + 'a0-06': '说地点用 I’m from + 地点:I’m from Hong Kong。', + 'a0-07': '介绍人用 This is my + 人:This is my mother。', + 'a0-08': '星期和整点都可用 It’s:It’s Monday. / It’s three o’clock。', + 'a0-09': '说喜好用 I like + 东西;一般问句用 Do you like + 东西?', + 'a0-10': '礼貌请求以 Please 开头:Please say that again.', +}; + +String grammarNoteForLesson(String id) => a0GrammarNotes[id] ?? ''; + +const a0SegmentGrammarNotes = { + 'a0-04-a': '数字单独说就可以:zero, one, two。每个词之间稍微停顿。', + 'a0-04-b': '认物品可以说 It is a phone. 口语里也常说 It’s a phone.', + 'a0-04-c': '报号码时一个数字一个数字说:one-three-eight,不把它读成一百三十八。', + 'a0-08-a': '问星期用 What day is it?;回答用 It is / It’s + 星期。', + 'a0-08-b': '星期四到星期日也沿用 It is / It’s + 星期这个句型。', + 'a0-08-c': '说整点用 It is / It’s + 数字 + o’clock,例如 It’s three o’clock。', +}; + +String grammarNoteForSegment(String segmentId, String lessonId) => + a0SegmentGrammarNotes[segmentId] ?? grammarNoteForLesson(lessonId); + +bool matchesSegmentDialogue(String segmentId, int stage, String response) { + final text = response.toLowerCase().replaceAll('’', "'"); + final segment = a0LessonSegments.values + .expand((segments) => segments) + .where((item) => item.id == segmentId) + .firstOrNull; + if (segment == null || stage >= segment.dialogueRequiredTerms.length) { + return false; + } + bool hasAny(Iterable terms) => + terms.any((term) => text.contains(term)); + // A phone-number report needs both the reporting frame and at least one + // spoken digit. Other segment turns need one reviewed, task-specific term. + if (segmentId == 'a0-04-c' && stage == 0) { + return text.contains('my number is') && + hasAny(segment.dialogueRequiredTerms[stage].skip(1)); + } + return hasAny(segment.dialogueRequiredTerms[stage]); +} + +bool matchesSegmentIndependent(String segmentId, String response) { + final text = response.toLowerCase().replaceAll('’', "'"); + final segment = a0LessonSegments.values + .expand((segments) => segments) + .where((item) => item.id == segmentId) + .firstOrNull; + if (segment == null) return true; + if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') { + final count = segment.independentRequiredTerms + .where((term) => text.contains(term)) + .length; + return count >= 3; + } + if (segmentId == 'a0-04-c') { + return segment.independentRequiredTerms + .where((term) => text.contains(term)) + .length >= + 3; + } + return segment.independentRequiredTerms.any((term) => text.contains(term)); +} + +class LessonDialogue { + const LessonDialogue({ + required this.goal, + required this.prompts, + required this.hints, + }); + final String goal; + final List prompts; + final List hints; +} + +const a0Dialogues = { + 'a0-01': LessonDialogue( + goal: '问候、介绍姓名并回应见面问候', + prompts: [ + 'Hi! I’m Mia. What’s your name?', + 'Nice to meet you. Say: Nice to meet you, too.', + 'Great! Say hello one more time.', + 'Now ask me my name!', + ], + hints: [ + 'I’m Alex.', + 'Nice to meet you, too.', + 'Hello!', + 'What’s your name?', + ], + ), + 'a0-02': LessonDialogue( + goal: '介绍姓名并完整拼读名字', + prompts: [ + 'Hi! What’s your name?', + 'How do you spell that?', + 'Thank you. Spell it one more time.', + 'Now ask me my name!', + ], + hints: ['My name is Alex.', 'A-L-E-X.', 'A-L-E-X.', 'What’s your name?'], + ), + 'a0-03': LessonDialogue( + goal: '询问状态、回答并反问', + prompts: [ + 'Hi! How are you today?', + 'Good! Ask me: How are you?', + 'I’m okay. Say your state one more time.', + 'Now say goodbye!', + ], + hints: ['I’m good, thanks.', 'How are you?', 'I’m okay.', 'Bye!'], + ), + 'a0-04': LessonDialogue( + goal: '报告一个虚拟三位号码并确认', + prompts: [ + 'What’s your phone number? Use a fake three-digit number.', + 'I heard one-three-eight. Is that right?', + 'Please say the three numbers again.', + 'Now ask me for my number!', + ], + hints: [ + 'My number is one-three-eight.', + 'Yes.', + 'One-three-eight.', + 'What’s your phone number?', + ], + ), + 'a0-05': LessonDialogue( + goal: '询问并说出一个物品', + prompts: [ + 'What’s this? It is a pen.', + 'Now say: It’s a pen.', + 'Ask me: What’s this?', + 'Great! Say one more object.', + ], + hints: ['It’s a pen.', 'It’s a pen.', 'What’s this?', 'It’s a book.'], + ), + 'a0-06': LessonDialogue( + goal: '说来自哪里并反问', + prompts: [ + 'Where are you from?', + 'Nice! Ask me: Where are you from?', + 'Say where you are from one more time.', + 'Say goodbye!', + ], + hints: [ + 'I’m from Hong Kong.', + 'Where are you from?', + 'I’m from Hong Kong.', + 'Bye!', + ], + ), + 'a0-07': LessonDialogue( + goal: '介绍一位家人或朋友', + prompts: [ + 'Who is this?', + 'Good. Say: This is my mother.', + 'Ask me: Who is this?', + 'Introduce one person again.', + ], + hints: [ + 'This is my mother.', + 'This is my mother.', + 'Who is this?', + 'This is my friend.', + ], + ), + 'a0-08': LessonDialogue( + goal: '说明星期或整点', + prompts: [ + 'What day is it today?', + 'What time is it?', + 'Say one full time sentence.', + 'Now ask me the day!', + ], + hints: [ + 'It’s Monday.', + 'It’s three o’clock.', + 'It’s three o’clock.', + 'What day is it?', + ], + ), + 'a0-09': LessonDialogue( + goal: '表达喜好、回答和反问', + prompts: [ + 'What do you like?', + 'Do you like tea?', + 'Now ask me what I like.', + 'Say your like one more time.', + ], + hints: ['I like tea.', 'Yes, I do.', 'Do you like tea?', 'I like tea.'], + ), + 'a0-10': LessonDialogue( + goal: '请求重复或放慢语速,并完成基础沟通', + prompts: [ + 'Say: Please say that again.', + 'Say: Please speak slowly.', + 'Now ask me your name or where I am from.', + 'Say one thing you like.', + ], + hints: [ + 'Please say that again.', + 'Please speak slowly.', + 'What’s your name?', + 'I like tea.', + ], + ), +}; + +const a0SegmentDialogues = { + 'a0-04-a': LessonDialogue( + goal: '听辨并说出 0 到 5', + prompts: ['Say zero, one, two.', 'Now say three, four, five.'], + hints: ['zero, one, two', 'three, four, five'], + ), + 'a0-04-b': LessonDialogue( + goal: '听辨 6 到 10 并认识 phone', + prompts: ['Say six, seven, eight.', 'What is this? Say: It is a phone.'], + hints: ['six, seven, eight', 'It is a phone.'], + ), + 'a0-04-c': LessonDialogue( + goal: '询问并报告三位号码', + prompts: ['What is your phone number?', 'Say a three-digit number again.'], + hints: ['My number is one-three-eight.', 'one-three-eight'], + ), + 'a0-08-a': LessonDialogue( + goal: '询问并说出星期一到三', + prompts: ['What day is it?', 'Say Monday, Tuesday, or Wednesday.'], + hints: ['What day is it?', 'It is Monday.'], + ), + 'a0-08-b': LessonDialogue( + goal: '说出星期四到日', + prompts: ['What day is it?', 'Say Thursday, Friday, Saturday, or Sunday.'], + hints: ['What day is it?', 'It is Friday.'], + ), + 'a0-08-c': LessonDialogue( + goal: '询问并说出整点', + prompts: ['What time is it?', 'Say one full time sentence.'], + hints: ['What time is it?', 'It is three o’clock.'], + ), +}; + +LessonDialogue dialogueByLessonId(String id) => a0Dialogues[id]!; + +LessonDialogue dialogueBySegmentId(String segmentId, String lessonId) => + a0SegmentDialogues[segmentId] ?? dialogueByLessonId(lessonId); diff --git a/kouyu_english/lib/core/voice_service.dart b/kouyu_english/lib/core/voice_service.dart new file mode 100644 index 0000000..bb90229 --- /dev/null +++ b/kouyu_english/lib/core/voice_service.dart @@ -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 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 stopSpeaking() => _tts.stop(); + + Future 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 stopRecording() => _recorder.stop(); + + Future playRecording(String path) async { + await _player.stop(); + await _player.play(DeviceFileSource(path)); + } + + Future stopRecordingPlayback() => _player.stop(); + + Future deleteRecording(String? path) async { + if (path == null || path.isEmpty) return; + final file = File(path); + if (await file.exists()) await file.delete(); + } + + Future 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()) { + await file.delete(); + } + return files.length; + } + + Future> 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() + .toList(); + files.sort((left, right) => right.path.compareTo(left.path)); + return files.map((file) => file.path).toList(); + } + + Future disposeRecording() async { + await _recorder.dispose(); + await _player.dispose(); + } + + Future initializeSpeech() async { + _speechReady = await _stt.initialize(); + return _speechReady; + } + + Future 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 stopListening() => _stt.stop(); + bool get isListening => _stt.isListening; +} diff --git a/kouyu_english/lib/core/writing_feedback.dart b/kouyu_english/lib/core/writing_feedback.dart new file mode 100644 index 0000000..7fc3630 --- /dev/null +++ b/kouyu_english/lib/core/writing_feedback.dart @@ -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 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 words, Iterable 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 / It’s 加 Monday、Tuesday 或 Wednesday。', + 'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。', + 'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。', + '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' => '分别写姓名、来自哪里和一项喜好三部分。', + _ => '再补充一个完整英文句子。', + }; +} diff --git a/kouyu_english/lib/features/assessment/assessment_page.dart b/kouyu_english/lib/features/assessment/assessment_page.dart new file mode 100644 index 0000000..f699a20 --- /dev/null +++ b/kouyu_english/lib/features/assessment/assessment_page.dart @@ -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 createState() => + _AssessmentPreparationPageState(); +} + +class _AssessmentPreparationPageState extends State { + bool? microphoneReady; + bool checkingMicrophone = false; + + Future _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 onStartReplacement; + @override + State createState() => _AssessmentPageState(); +} + +class _AssessmentPageState extends State { + final controller = TextEditingController(); + final Map 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 _play() async { + await VoiceService.instance.speak(task.audio!); + if (mounted) setState(() => audioPlayed = true); + } + + Future _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 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 => '写作', + }; +} diff --git a/kouyu_english/lib/features/dialogue/dialogue_flow.dart b/kouyu_english/lib/features/dialogue/dialogue_flow.dart new file mode 100644 index 0000000..79e9764 --- /dev/null +++ b/kouyu_english/lib/features/dialogue/dialogue_flow.dart @@ -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 onFinished; + final bool isLessonDialogue; + @override + State createState() => _DialoguePageState(); +} + +class _DialoguePageState extends State { + final controller = TextEditingController(); + final List 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. What’s your name?', + 'Nice to meet you. Where are you from?', + 'Great! How are you today? Or what do you like?', + 'I’m good, thanks. Now ask me one question!', + ]; + static const hints = [ + 'My name is Alex.', + 'I’m from Hong Kong.', + 'I’m good, thanks. / I like coffee.', + 'What’s 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 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) => { + '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 _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 _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 _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 _playRecording() async { + final path = recordingPath; + if (path == null) return; + setState(() => playingRecording = true); + await VoiceService.instance.playRecording(path); + if (mounted) setState(() => playingRecording = false); + } + + Future _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), + ], + ), + ), + ], + ); +} diff --git a/kouyu_english/lib/features/home/home_page.dart b/kouyu_english/lib/features/home/home_page.dart new file mode 100644 index 0000000..70a59d0 --- /dev/null +++ b/kouyu_english/lib/features/home/home_page.dart @@ -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, + ), + ), + ), + ], + ), + ); +} diff --git a/kouyu_english/lib/features/lesson/lesson_flow.dart b/kouyu_english/lib/features/lesson/lesson_flow.dart new file mode 100644 index 0000000..7956574 --- /dev/null +++ b/kouyu_english/lib/features/lesson/lesson_flow.dart @@ -0,0 +1,1143 @@ +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 '../../core/writing_feedback.dart'; +import '../../widgets/app_widgets.dart'; +import '../../widgets/lexicon_lookup.dart'; + +class LessonFlow extends StatefulWidget { + const LessonFlow({ + super.key, + required this.state, + required this.onOpenDialogue, + required this.onFinish, + }); + + final AppState state; + final VoidCallback onOpenDialogue; + final VoidCallback onFinish; + + @override + State createState() => _LessonFlowState(); +} + +class _LessonFlowState extends State { + final writingController = TextEditingController(); + final independentController = TextEditingController(); + int selectedAnswer = -1; + int previewIndex = 0; + bool listeningAudioPlayed = false; + bool showWritingHelp = true; + bool showIndependentHelp = false; + LessonSegment get segment { + final lesson = lessonById(widget.state.activeLessonId); + return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)]; + } + + LessonActivity get activity => + activityBySegmentId(segment.id, widget.state.activeLessonId); + + List get previewItems => + vocabularyBySegmentId(segment.id, widget.state.activeLessonId); + + @override + void initState() { + super.initState(); + writingController.text = widget.state.lessonWritingDraft; + independentController.text = widget.state.independentAttemptDraft; + previewIndex = widget.state.previewIndex + .clamp(0, previewItems.length - 1) + .toInt(); + } + + @override + void dispose() { + writingController.dispose(); + independentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final lesson = lessonById(widget.state.activeLessonId); + final Widget content; + switch (widget.state.lessonStep) { + case LessonStep.preview: + content = _PreviewStep( + state: widget.state, + item: previewItems[previewIndex], + position: previewIndex + 1, + total: previewItems.length, + onLookup: () => showLexiconLookup( + context, + state: widget.state, + initialText: previewItems[previewIndex].word, + ), + onNext: () { + if (previewIndex < previewItems.length - 1) { + setState(() => previewIndex += 1); + widget.state.setPreviewIndex(previewIndex); + } else { + widget.state.completePreview(); + } + }, + onSkip: widget.state.completePreview, + ); + case LessonStep.listening: + content = _ListeningStep( + state: widget.state, + activity: activity, + selectedAnswer: selectedAnswer, + audioPlayed: listeningAudioPlayed, + onSelected: (value) => setState(() => selectedAnswer = value), + onPlayed: () => setState(() => listeningAudioPlayed = true), + onLookup: () => showLexiconLookup( + context, + state: widget.state, + initialText: activity.listening, + ), + onContinue: listeningAudioPlayed && selectedAnswer == 0 + ? widget.state.completeListening + : null, + ); + case LessonStep.speaking: + content = _SpeakingStep( + state: widget.state, + text: activity.speaking, + keepRecording: widget.state.keepRecordings, + onContinue: () => widget.state.completeSpeaking(assisted: true), + ); + case LessonStep.reading: + content = _ReadingStep( + state: widget.state, + activity: activity, + onLookup: () => showLexiconLookup( + context, + state: widget.state, + initialText: activity.reading, + ), + onContinue: widget.state.completeReading, + ); + case LessonStep.writing: + content = _WritingStep( + state: widget.state, + lessonId: widget.state.activeLessonId, + segmentId: segment.id, + activity: activity, + controller: writingController, + showHelp: showWritingHelp, + canContinue: writingController.text.trim().isNotEmpty, + onChanged: () { + widget.state.setLessonWritingDraft(writingController.text); + setState(() {}); + }, + onToggleHelp: () => + setState(() => showWritingHelp = !showWritingHelp), + onContinue: (usedAiFeedback) => widget.state.completeWriting( + assisted: showWritingHelp || usedAiFeedback, + rawAnswer: writingController.text.trim(), + ), + ); + case LessonStep.dialogue: + content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue); + case LessonStep.independent: + content = _IndependentStep( + segmentId: segment.id, + keepRecording: widget.state.keepRecordings, + activity: activity, + controller: independentController, + showHelp: showIndependentHelp, + canContinue: independentController.text.trim().isNotEmpty, + onChanged: () { + widget.state.setIndependentAttemptDraft(independentController.text); + setState(() {}); + }, + onNeedHelp: () => setState(() => showIndependentHelp = true), + onLookup: () { + setState(() => showIndependentHelp = true); + showLexiconLookup( + context, + state: widget.state, + initialText: activity.independentPrompt, + ); + }, + onContinue: (spoken, recordingPath) => + widget.state.completeIndependentAttempt( + assisted: showIndependentHelp, + spoken: spoken, + rawAnswer: independentController.text.trim(), + recordingPath: recordingPath, + ), + onLater: () => + widget.state.completeIndependentAttempt(assisted: true), + ); + case LessonStep.complete: + content = _CompletionStep( + assisted: widget.state.independentAttemptAssisted, + isFinalSegment: + widget.state.activeSegmentIndexFor(lesson.id) == + lesson.segments.length - 1, + nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2, + onFinish: () { + widget.state.finishCurrentLessonSegment(); + widget.onFinish(); + }, + ); + } + return _LessonScope( + title: + '第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段', + child: content, + ); + } +} + +class _LessonScaffold extends StatelessWidget { + const _LessonScaffold({required this.step, required this.child}); + + final int step; + final Widget child; + + @override + Widget build(BuildContext context) => AppPage( + appBar: AppBar(title: Text(_LessonScope.of(context))), + child: SpacedColumn( + spacing: 16, + children: [ + Row( + children: List.generate( + 6, + (index) => Expanded( + child: Container( + height: 6, + margin: EdgeInsets.only(right: index == 5 ? 0 : 5), + decoration: BoxDecoration( + color: index < step ? AppColors.green : AppColors.line, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + ), + child, + ], + ), + ); +} + +class _LessonScope extends InheritedWidget { + const _LessonScope({required this.title, required super.child}); + + final String title; + + static String of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ?? + 'A0 课程练习'; + + @override + bool updateShouldNotify(_LessonScope oldWidget) => title != oldWidget.title; +} + +class _PreviewStep extends StatelessWidget { + const _PreviewStep({ + required this.state, + required this.item, + required this.position, + required this.total, + required this.onLookup, + required this.onNext, + required this.onSkip, + }); + + final VocabularyItem item; + final AppState state; + final int position; + final int total; + final VoidCallback onLookup; + final VoidCallback onNext; + final VoidCallback onSkip; + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 1, + child: SpacedColumn( + children: [ + Eyebrow('先认识今天的词 · $position / $total'), + Text('后面会遇到这些词。', style: Theme.of(context).textTheme.headlineMedium), + const Text('先听一遍、知道意思就够了,不用马上背会。'), + SectionCard( + tint: AppColors.softGreen, + child: SpacedColumn( + children: [ + Text( + item.word, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + ), + ), + if (item.ipa != null) + Text(item.ipa!, style: Theme.of(context).textTheme.bodyMedium), + Text(item.meaning, style: const TextStyle(fontSize: 17)), + _AudioRow(label: '播放示范音', speech: item.word), + LexiconText(item.example, state: state), + Text(item.exampleMeaning), + ], + ), + ), + Wrap( + spacing: 8, + children: [ActionChip(label: const Text('查词'), onPressed: onLookup)], + ), + PrimaryButton( + label: position == total ? '进入课程' : '认识了,下一个', + onPressed: onNext, + ), + TextButton(onPressed: onSkip, child: const Text('跳过,直接进入课程')), + ], + ), + ); +} + +class _ListeningStep extends StatelessWidget { + const _ListeningStep({ + required this.state, + required this.activity, + required this.selectedAnswer, + required this.audioPlayed, + required this.onSelected, + required this.onPlayed, + required this.onLookup, + required this.onContinue, + }); + + final LessonActivity activity; + final AppState state; + + final int selectedAnswer; + final bool audioPlayed; + final ValueChanged onSelected; + final VoidCallback onPlayed; + final VoidCallback onLookup; + final VoidCallback? onContinue; + + @override + Widget build(BuildContext context) { + final answers = activity.answers; + return _LessonScaffold( + step: 2, + child: SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('听一听'), + Text( + activity.listeningQuestion, + style: Theme.of(context).textTheme.headlineMedium, + ), + SectionCard( + tint: AppColors.surfaceMuted, + child: _AudioRow( + label: audioPlayed ? '再播放一次' : '播放问题', + speech: activity.listening, + onPlayed: onPlayed, + ), + ), + TextButton.icon( + onPressed: onLookup, + icon: const Icon(Icons.menu_book_outlined), + label: const Text('查看词或短语'), + ), + if (audioPlayed) + SectionCard(child: LexiconText(activity.listening, state: state)), + for (var index = 0; index < answers.length; index++) + SectionCard( + tint: selectedAnswer == index ? AppColors.softGreen : null, + onTap: audioPlayed ? () => onSelected(index) : null, + child: Row( + children: [ + Icon( + selectedAnswer == index + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: selectedAnswer == index + ? AppColors.green + : AppColors.muted, + ), + const SizedBox(width: 10), + Text(answers[index]), + ], + ), + ), + PrimaryButton( + label: audioPlayed ? '检查并继续' : '先播放音频', + onPressed: onContinue, + ), + if (selectedAnswer >= 0 && selectedAnswer != 0) + const Text( + '再听一次,选择正确答案。', + style: TextStyle(color: AppColors.warmInk), + ), + ], + ), + ); + } +} + +class _SpeakingStep extends StatefulWidget { + const _SpeakingStep({ + required this.state, + required this.text, + required this.keepRecording, + required this.onContinue, + }); + final String text; + final AppState state; + final bool keepRecording; + final VoidCallback onContinue; + + @override + State<_SpeakingStep> createState() => _SpeakingStepState(); +} + +class _SpeakingStepState extends State<_SpeakingStep> { + bool listening = false; + bool recording = false; + bool playingRecording = false; + String transcript = ''; + String? recordingPath; + + @override + void dispose() { + VoiceService.instance.stopRecordingPlayback(); + if (!widget.keepRecording) { + VoiceService.instance.deleteRecording(recordingPath); + } + super.dispose(); + } + + Future _toggleMic() async { + if (listening) { + await VoiceService.instance.stopListening(); + if (mounted) setState(() => listening = false); + return; + } + final ready = await VoiceService.instance.startListening((text, _) { + if (mounted) setState(() => transcript = text); + }); + if (mounted) setState(() => listening = ready); + } + + Future _toggleRecording() async { + if (recording) { + final path = await VoiceService.instance.stopRecording(); + if (mounted) { + setState(() { + recording = false; + recordingPath = path; + }); + } + return; + } + final ready = await VoiceService.instance.startRecording(); + if (!mounted) return; + setState(() => recording = ready); + if (!ready) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。'))); + } + } + + Future _playRecording() async { + final path = recordingPath; + if (path == null) return; + setState(() => playingRecording = true); + await VoiceService.instance.playRecording(path); + if (mounted) setState(() => playingRecording = false); + } + + Future _deleteRecording() async { + await VoiceService.instance.deleteRecording(recordingPath); + if (mounted) setState(() => recordingPath = null); + } + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 3, + child: SpacedColumn( + children: [ + const Eyebrow('跟读'), + Text('先听,再说。', style: Theme.of(context).textTheme.headlineMedium), + LexiconText( + widget.text, + state: widget.state, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w600), + ), + Text( + '/es - eɪtʃ - iː - en/', + style: Theme.of(context).textTheme.bodyMedium, + ), + SectionCard( + tint: AppColors.surfaceMuted, + child: _AudioRow(label: '播放示范音', speech: widget.text), + ), + const SectionCard( + tint: AppColors.warm, + child: Text( + '字母之间留一个短停顿。先清楚,不必快。', + style: TextStyle(color: AppColors.warmInk), + ), + ), + SecondaryButton( + label: listening ? '停止录音' : '使用麦克风跟读', + onPressed: _toggleMic, + ), + SecondaryButton( + label: recording ? '停止本机录音' : '录音后回听', + onPressed: listening ? null : _toggleRecording, + ), + if (recordingPath != null) + SectionCard( + tint: AppColors.softGreen, + child: SpacedColumn( + spacing: 8, + children: [ + Text(widget.keepRecording ? '录音已保存在本机。' : '本次录音仅在离开此步骤前保留。'), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: playingRecording ? null : _playRecording, + icon: const Icon(Icons.play_arrow), + label: const Text('回听'), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: '删除录音', + onPressed: _deleteRecording, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ], + ), + ), + if (transcript.isNotEmpty) + SectionCard(child: Text('设备转写:$transcript\n请确认它是否接近你刚才说的内容。')), + const SectionCard( + child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'), + ), + PrimaryButton( + label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续', + onPressed: widget.onContinue, + ), + ], + ), + ); +} + +class _ReadingStep extends StatefulWidget { + const _ReadingStep({ + required this.state, + required this.activity, + required this.onLookup, + required this.onContinue, + }); + final LessonActivity activity; + final AppState state; + final VoidCallback onLookup; + final VoidCallback onContinue; + + @override + State<_ReadingStep> createState() => _ReadingStepState(); +} + +class _ReadingStepState extends State<_ReadingStep> { + final controller = TextEditingController(); + bool showAnswer = false; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + bool get isCorrect { + final answer = widget.activity.readingAnswer.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + final response = controller.text.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + return response.isNotEmpty && response.contains(answer); + } + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 4, + child: SpacedColumn( + children: [ + const Eyebrow('读一读'), + Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium), + SectionCard( + tint: AppColors.softGreen, + child: LexiconText( + widget.activity.reading, + state: widget.state, + style: TextStyle(fontSize: 16, height: 1.6), + ), + ), + Text(widget.activity.readingQuestion), + TextButton.icon( + onPressed: widget.onLookup, + icon: const Icon(Icons.menu_book_outlined), + label: const Text('查词或短语'), + ), + TextField( + controller: controller, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + hintText: '用英文输入答案', + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder(), + ), + ), + if (showAnswer) + SectionCard( + tint: AppColors.warm, + child: Text( + '答案:${widget.activity.readingAnswer}', + style: const TextStyle(color: AppColors.warmInk), + ), + ), + if (!showAnswer && controller.text.isNotEmpty && !isCorrect) + TextButton( + onPressed: () => setState(() => showAnswer = true), + child: const Text('查看答案后继续学习'), + ), + PrimaryButton( + label: showAnswer ? '继续写一写' : '检查并继续', + onPressed: showAnswer || isCorrect ? widget.onContinue : null, + ), + ], + ), + ); +} + +class _WritingStep extends StatefulWidget { + const _WritingStep({ + required this.state, + required this.lessonId, + required this.segmentId, + required this.activity, + required this.controller, + required this.showHelp, + required this.canContinue, + required this.onChanged, + required this.onToggleHelp, + required this.onContinue, + }); + final String lessonId; + final AppState state; + final String segmentId; + final LessonActivity activity; + final TextEditingController controller; + final bool showHelp; + final bool canContinue; + final VoidCallback onChanged; + final VoidCallback onToggleHelp; + final ValueChanged onContinue; + + @override + State<_WritingStep> createState() => _WritingStepState(); +} + +class _WritingStepState extends State<_WritingStep> { + WritingCheckResult? result; + WritingAiFeedback? aiFeedback; + String? aiFeedbackError; + bool requestingAiFeedback = false; + + void _checkOrContinue() { + if (result?.complete == true) { + widget.onContinue(aiFeedback != null); + return; + } + setState( + () => result = WritingFeedback.check( + widget.lessonId, + widget.controller.text, + segmentId: widget.segmentId, + ), + ); + } + + Future _requestAiFeedback() async { + if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return; + if (widget.state.aiProvider == AiProviderType.mock) { + setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。'); + return; + } + setState(() { + requestingAiFeedback = true; + aiFeedbackError = null; + }); + final feedback = await AiService.instance.writingFeedback( + provider: widget.state.aiProvider, + endpoint: widget.state.aiEndpoint, + model: widget.state.aiModel, + lessonId: widget.lessonId, + taskPrompt: widget.activity.writingPrompt, + answer: widget.controller.text.trim(), + ); + if (!mounted) return; + setState(() { + requestingAiFeedback = false; + aiFeedback = feedback; + aiFeedbackError = feedback == null + ? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。' + : null; + }); + } + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 5, + child: SpacedColumn( + children: [ + const Eyebrow('写一写'), + Text( + widget.activity.writingPrompt, + style: Theme.of(context).textTheme.headlineMedium, + ), + SectionCard( + tint: AppColors.surfaceMuted, + child: Text( + '小提示:${grammarNoteForSegment(widget.segmentId, widget.lessonId)}', + ), + ), + if (widget.showHelp) + SectionCard( + tint: AppColors.softGreen, + child: Text( + widget.activity.writingExample, + style: TextStyle(fontSize: 18, height: 1.5), + ), + ), + TextField( + controller: widget.controller, + onChanged: (_) { + setState(() { + result = null; + aiFeedback = null; + aiFeedbackError = null; + }); + widget.onChanged(); + }, + minLines: 3, + maxLines: 4, + decoration: InputDecoration( + labelText: '你的答案', + hintText: widget.showHelp + ? widget.activity.writingExample + : '请输入完整英文答案', + filled: true, + fillColor: AppColors.surface, + border: const OutlineInputBorder(), + ), + ), + TextButton( + onPressed: widget.onToggleHelp, + child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'), + ), + OutlinedButton.icon( + onPressed: widget.canContinue && !requestingAiFeedback + ? _requestAiFeedback + : null, + icon: requestingAiFeedback + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome_outlined), + label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'), + ), + if (aiFeedback != null) + SectionCard( + tint: aiFeedback!.verdict == 'accepted' + ? AppColors.softGreen + : AppColors.warm, + child: SpacedColumn( + spacing: 6, + children: [ + Text('AI 建议:${aiFeedback!.feedback}'), + if (aiFeedback!.missing.isNotEmpty) + Text('还可补充:${aiFeedback!.missing.join('、')}'), + if (aiFeedback!.suggestion != null) + Text('可参考改写:${aiFeedback!.suggestion}'), + const Text( + '这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。', + style: TextStyle(fontSize: 12, color: AppColors.muted), + ), + ], + ), + ), + if (aiFeedbackError != null) + SectionCard( + tint: AppColors.warm, + child: Text( + aiFeedbackError!, + style: const TextStyle(color: AppColors.warmInk), + ), + ), + if (result != null) + SectionCard( + tint: result!.complete ? AppColors.softGreen : AppColors.warm, + child: Text( + result!.message, + style: TextStyle( + color: result!.complete ? AppColors.green : AppColors.warmInk, + ), + ), + ), + PrimaryButton( + label: result?.complete == true ? '进入课程对话' : '检查句子', + onPressed: widget.canContinue ? _checkOrContinue : null, + ), + ], + ), + ); +} + +class _DialoguePendingStep extends StatelessWidget { + const _DialoguePendingStep({required this.onOpenDialogue}); + final VoidCallback onOpenDialogue; + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 6, + child: SpacedColumn( + children: [ + const Eyebrow('课程对话'), + Text('在情境中用上刚学的句子。', style: Theme.of(context).textTheme.headlineMedium), + const Text('完成姓名、地点、一个状态或喜好,并反问对方。'), + PrimaryButton(label: '开始文字对话', onPressed: onOpenDialogue), + ], + ), + ); +} + +class _IndependentStep extends StatefulWidget { + const _IndependentStep({ + required this.segmentId, + required this.keepRecording, + required this.activity, + required this.controller, + required this.showHelp, + required this.canContinue, + required this.onChanged, + required this.onNeedHelp, + required this.onLookup, + required this.onContinue, + required this.onLater, + }); + final LessonActivity activity; + final String segmentId; + final bool keepRecording; + final TextEditingController controller; + final bool showHelp; + final bool canContinue; + final VoidCallback onChanged; + final VoidCallback onNeedHelp; + final VoidCallback onLookup; + final void Function(bool spoken, String? recordingPath) onContinue; + final VoidCallback onLater; + + @override + State<_IndependentStep> createState() => _IndependentStepState(); +} + +class _IndependentStepState extends State<_IndependentStep> { + bool listening = false; + bool recording = false; + bool playingRecording = false; + bool usedVoice = false; + bool transcriptEdited = false; + String lastTranscript = ''; + String? recordingPath; + String? validationError; + + @override + void dispose() { + VoiceService.instance.stopRecordingPlayback(); + if (!widget.keepRecording) { + VoiceService.instance.deleteRecording(recordingPath); + } + super.dispose(); + } + + void _submit() { + if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) { + setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。'); + return; + } + widget.onContinue( + usedVoice && !transcriptEdited, + widget.keepRecording ? recordingPath : null, + ); + } + + Future _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 _playRecording() async { + final path = recordingPath; + if (path == null) return; + setState(() => playingRecording = true); + await VoiceService.instance.playRecording(path); + if (mounted) setState(() => playingRecording = false); + } + + Future _deleteRecording() async { + await VoiceService.instance.deleteRecording(recordingPath); + if (mounted) setState(() => recordingPath = null); + } + + Future _toggleMic() async { + if (listening) { + await VoiceService.instance.stopListening(); + if (mounted) setState(() => listening = false); + return; + } + final ready = await VoiceService.instance.startListening((text, _) { + if (!mounted) return; + setState(() { + widget.controller.text = text; + usedVoice = true; + lastTranscript = text; + transcriptEdited = false; + }); + widget.onChanged(); + }); + if (mounted) setState(() => listening = ready); + if (!ready && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成写作练习。'))); + } + } + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 6, + child: SpacedColumn( + children: [ + const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'), + Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium), + Text(widget.activity.independentPrompt), + if (widget.showHelp) + SectionCard( + tint: AppColors.warm, + child: Text( + '帮助:${widget.activity.independentHelp}', + style: TextStyle(color: AppColors.warmInk), + ), + ), + TextField( + controller: widget.controller, + onChanged: (value) { + if (usedVoice && value != lastTranscript) transcriptEdited = true; + setState(() {}); + widget.onChanged(); + }, + minLines: 2, + decoration: InputDecoration( + hintText: '输入完整英文句子', + filled: true, + fillColor: AppColors.surface, + prefixIcon: IconButton( + tooltip: listening ? '停止录音' : '语音输入', + icon: Icon( + listening ? Icons.stop_circle_outlined : Icons.mic_none, + ), + onPressed: _toggleMic, + ), + border: OutlineInputBorder(), + ), + ), + SectionCard( + tint: AppColors.surfaceMuted, + child: SpacedColumn( + spacing: 8, + children: [ + Text( + widget.keepRecording + ? '可录下这次尝试并保存在本机;不会发送给 AI。' + : '可录下这次尝试并回听;离开本页后会自动删除。', + ), + 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) ...[ + 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 ? AppColors.warm : AppColors.softGreen, + child: Text( + transcriptEdited + ? '你修改了设备转写:这次会按文字练习保存,不计口语练习。' + : '这是设备转写。未修改并确认后,会保留为本次语音练习记录。', + style: TextStyle( + color: transcriptEdited ? AppColors.warmInk : AppColors.green, + ), + ), + ), + if (validationError != null) + SectionCard( + tint: AppColors.warm, + child: Text( + validationError!, + style: const TextStyle(color: AppColors.warmInk), + ), + ), + if (!widget.showHelp) + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: widget.onNeedHelp, + child: const Text('需要帮助'), + ), + TextButton( + onPressed: widget.onLookup, + child: const Text('查词或短语'), + ), + ], + ) + else + TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')), + PrimaryButton( + label: widget.showHelp ? '带帮助完成' : '独立完成', + onPressed: widget.canContinue ? _submit : null, + ), + TextButton(onPressed: widget.onLater, child: const Text('稍后继续')), + ], + ), + ); +} + +class _CompletionStep extends StatelessWidget { + const _CompletionStep({ + required this.assisted, + required this.isFinalSegment, + required this.nextSegmentNumber, + required this.onFinish, + }); + final bool assisted; + final bool isFinalSegment; + final int nextSegmentNumber; + final VoidCallback onFinish; + + @override + Widget build(BuildContext context) => _LessonScaffold( + step: 6, + child: SpacedColumn( + children: [ + const Eyebrow('本段已保存'), + Text( + isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。', + style: Theme.of(context).textTheme.headlineMedium, + ), + Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'), + if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'), + PrimaryButton(label: '回到首页', onPressed: onFinish), + ], + ), + ); +} + +class _AudioRow extends StatelessWidget { + const _AudioRow({required this.label, this.speech, this.onPlayed}); + final String label; + final String? speech; + final VoidCallback? onPlayed; + + @override + Widget build(BuildContext context) => Row( + children: [ + IconButton.filled( + onPressed: () async { + await VoiceService.instance.speak(speech ?? label); + onPlayed?.call(); + }, + icon: const Icon(Icons.play_arrow), + ), + const SizedBox(width: 10), + Expanded(child: Text(label)), + TextButton( + onPressed: () => + VoiceService.instance.speak(speech ?? label, slow: true), + child: const Text('慢速'), + ), + ], + ); +} diff --git a/kouyu_english/lib/features/onboarding/onboarding_pages.dart b/kouyu_english/lib/features/onboarding/onboarding_pages.dart new file mode 100644 index 0000000..ffb38bf --- /dev/null +++ b/kouyu_english/lib/features/onboarding/onboarding_pages.dart @@ -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 createState() => _WelcomePageState(); +} + +class _WelcomePageState extends State { + 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( + title: '你的主要目标', + value: selectedGoal, + options: const { + LearningGoal.dailyLife: '日常生活', + LearningGoal.travel: '旅行', + LearningGoal.workStarter: '工作起步', + }, + onChanged: (value) => setState(() => selectedGoal = value), + ), + _ChoiceGroup( + 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 createState() => _PlacementPageState(); +} + +class _PlacementPageState extends State { + 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 extends StatelessWidget { + const _ChoiceGroup({ + required this.title, + required this.value, + required this.options, + required this.onChanged, + }); + + final String title; + final T value; + final Map options; + final ValueChanged 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(), + ), + ], + ), + ); + } +} diff --git a/kouyu_english/lib/features/progress/progress_pages.dart b/kouyu_english/lib/features/progress/progress_pages.dart new file mode 100644 index 0000000..df4f4df --- /dev/null +++ b/kouyu_english/lib/features/progress/progress_pages.dart @@ -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 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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + 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( + 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://…/v1;Gemini: 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 _confirmDeleteRecordings(BuildContext context) async { + final confirmed = await showDialog( + 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 _showRecordings( + BuildContext context, + ) => showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: FutureBuilder>( + 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 _chooseDuration(BuildContext context) async { + final value = await showModalBottomSheet( + 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( + 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('确认清除'), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/kouyu_english/lib/features/review/review_page.dart b/kouyu_english/lib/features/review/review_page.dart new file mode 100644 index 0000000..fdba7bd --- /dev/null +++ b/kouyu_english/lib/features/review/review_page.dart @@ -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 createState() => _ReviewPageState(); +} + +class _ReviewPageState extends State { + 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 _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 _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 createState() => _AdaptiveLessonPageState(); +} + +class _AdaptiveLessonPageState extends State { + 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 _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 _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 _playRecording() async { + final path = recordingPath; + if (path == null) return; + setState(() => playingRecording = true); + await VoiceService.instance.playRecording(path); + if (mounted) setState(() => playingRecording = false); + } + + Future _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')}'; +} diff --git a/kouyu_english/lib/features/shell/learning_shell.dart b/kouyu_english/lib/features/shell/learning_shell.dart new file mode 100644 index 0000000..fe92082 --- /dev/null +++ b/kouyu_english/lib/features/shell/learning_shell.dart @@ -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 createState() => _LearningShellState(); +} + +class _LearningShellState extends State { + 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 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, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/kouyu_english/lib/main.dart b/kouyu_english/lib/main.dart new file mode 100644 index 0000000..d271d57 --- /dev/null +++ b/kouyu_english/lib/main.dart @@ -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 createState() => _KouyuEnglishAppState(); +} + +class _KouyuEnglishAppState extends State { + 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(), + ); + }, + ), + ); + } +} diff --git a/kouyu_english/lib/widgets/app_widgets.dart b/kouyu_english/lib/widgets/app_widgets.dart new file mode 100644 index 0000000..53900dd --- /dev/null +++ b/kouyu_english/lib/widgets/app_widgets.dart @@ -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 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), + ], + ], + ); +} diff --git a/kouyu_english/lib/widgets/lexicon_lookup.dart b/kouyu_english/lib/widgets/lexicon_lookup.dart new file mode 100644 index 0000000..da6d0e1 --- /dev/null +++ b/kouyu_english/lib/widgets/lexicon_lookup.dart @@ -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 get courseLexiconEntries { + final entries = [ + ...a0SeedLessons.expand((lesson) => lesson.vocabulary), + ...a0SegmentVocabulary.values.expand((items) => items), + ]; + final seen = {}; + 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().firstWhere((entry) { + final word = entry!.word.toLowerCase().replaceAll('’', "'"); + if (normalized == word) return true; + final pattern = RegExp( + r'(? 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 createState() => _LexiconTextState(); +} + +class _LexiconTextState extends State { + final List _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'(? RegExp.escape(item.word).replaceAll('’', "['’]"), + ) + .join('|') + + r')(?![a-zA-Z0-9])', + caseSensitive: false, + ); + final spans = []; + 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 showLexiconLookup( + BuildContext context, { + required AppState state, + String initialText = '', +}) => showModalBottomSheet( + 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 _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); + }, + ), + ], + ], + ), + ), + ); +} diff --git a/kouyu_english/macos/.gitignore b/kouyu_english/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/kouyu_english/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/kouyu_english/macos/Flutter/Flutter-Debug.xcconfig b/kouyu_english/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/kouyu_english/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/kouyu_english/macos/Flutter/Flutter-Release.xcconfig b/kouyu_english/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/kouyu_english/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/kouyu_english/macos/Podfile b/kouyu_english/macos/Podfile new file mode 100644 index 0000000..a46f7f2 --- /dev/null +++ b/kouyu_english/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '11.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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_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_macos_build_settings(target) + end +end diff --git a/kouyu_english/macos/Runner.xcodeproj/project.pbxproj b/kouyu_english/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..125c3dd --- /dev/null +++ b/kouyu_english/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 7327258E97F3BAB8C800F4E4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98F428CB880A51E1ED796CEA /* Pods_Runner.framework */; }; + F2B50FDC963F5CE03DB233C3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4767F86EB3D2FE503B5D8B5F /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* kouyu_english.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kouyu_english.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 44ECD61AD36F3E9A362E89AB /* 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 = ""; }; + 4767F86EB3D2FE503B5D8B5F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5671F8B606A5DC56FF39D729 /* 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 = ""; }; + 73404D1FE24EA23617C5E2B8 /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 98F428CB880A51E1ED796CEA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9CAE0B06B0DA31B4DE99A045 /* 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 = ""; }; + AA0F14EEE1607B42EFA77C88 /* 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 = ""; }; + EF2A7E190FF27B19B3D11782 /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F2B50FDC963F5CE03DB233C3 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7327258E97F3BAB8C800F4E4 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0FBC3E2AE1CFC9A532228CC3 /* Pods */ = { + isa = PBXGroup; + children = ( + AA0F14EEE1607B42EFA77C88 /* Pods-Runner.debug.xcconfig */, + 44ECD61AD36F3E9A362E89AB /* Pods-Runner.release.xcconfig */, + EF2A7E190FF27B19B3D11782 /* Pods-Runner.profile.xcconfig */, + 9CAE0B06B0DA31B4DE99A045 /* Pods-RunnerTests.debug.xcconfig */, + 73404D1FE24EA23617C5E2B8 /* Pods-RunnerTests.release.xcconfig */, + 5671F8B606A5DC56FF39D729 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 0FBC3E2AE1CFC9A532228CC3 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* kouyu_english.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 98F428CB880A51E1ED796CEA /* Pods_Runner.framework */, + 4767F86EB3D2FE503B5D8B5F /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 02F7385764B2D9B42ABAC383 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 8156BF69D8494EBBB3008F24 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 67C033A3B96A54CD4A06A9B9 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* kouyu_english.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 02F7385764B2D9B42ABAC383 /* [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; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 67C033A3B96A54CD4A06A9B9 /* [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; + }; + 8156BF69D8494EBBB3008F24 /* [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; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9CAE0B06B0DA31B4DE99A045 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/kouyu_english.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kouyu_english"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 73404D1FE24EA23617C5E2B8 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/kouyu_english.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kouyu_english"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5671F8B606A5DC56FF39D729 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/kouyu_english.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kouyu_english"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/kouyu_english/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kouyu_english/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kouyu_english/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kouyu_english/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kouyu_english/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..5cf18d0 --- /dev/null +++ b/kouyu_english/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/macos/Runner.xcworkspace/contents.xcworkspacedata b/kouyu_english/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/kouyu_english/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/kouyu_english/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kouyu_english/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kouyu_english/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kouyu_english/macos/Runner/AppDelegate.swift b/kouyu_english/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/kouyu_english/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/kouyu_english/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/kouyu_english/macos/Runner/Base.lproj/MainMenu.xib b/kouyu_english/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/kouyu_english/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kouyu_english/macos/Runner/Configs/AppInfo.xcconfig b/kouyu_english/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..f6790d4 --- /dev/null +++ b/kouyu_english/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = kouyu_english + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.shen.kouyuEnglish + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.shen. All rights reserved. diff --git a/kouyu_english/macos/Runner/Configs/Debug.xcconfig b/kouyu_english/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/kouyu_english/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/kouyu_english/macos/Runner/Configs/Release.xcconfig b/kouyu_english/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/kouyu_english/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/kouyu_english/macos/Runner/Configs/Warnings.xcconfig b/kouyu_english/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/kouyu_english/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/kouyu_english/macos/Runner/DebugProfile.entitlements b/kouyu_english/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..7d7b4dc --- /dev/null +++ b/kouyu_english/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.device.audio-input + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/kouyu_english/macos/Runner/Info.plist b/kouyu_english/macos/Runner/Info.plist new file mode 100644 index 0000000..76f2755 --- /dev/null +++ b/kouyu_english/macos/Runner/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSMicrophoneUsageDescription + 用于录制和转写你的英语练习,供你回听或确认;录音只保存在本机。 + NSSpeechRecognitionUsageDescription + 用于识别英语练习内容;你可以改用文字输入。 + NSPrincipalClass + NSApplication + + diff --git a/kouyu_english/macos/Runner/MainFlutterWindow.swift b/kouyu_english/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/kouyu_english/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/kouyu_english/macos/Runner/Release.entitlements b/kouyu_english/macos/Runner/Release.entitlements new file mode 100644 index 0000000..afafa97 --- /dev/null +++ b/kouyu_english/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.network.client + + + diff --git a/kouyu_english/macos/RunnerTests/RunnerTests.swift b/kouyu_english/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/kouyu_english/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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. + } + +} diff --git a/kouyu_english/pubspec.lock b/kouyu_english/pubspec.lock new file mode 100644 index 0000000..394614f --- /dev/null +++ b/kouyu_english/pubspec.lock @@ -0,0 +1,794 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d + url: "https://pub.dev" + source: hosted + version: "6.7.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + url: "https://pub.dev" + source: hosted + version: "4.3.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + drift: + dependency: "direct main" + description: + name: drift + sha256: e3b6352aa02e60bed6ffc3cc9894c421578dad5fbef21b1f7f57cdc0e4edabcb + url: "https://pub.dev" + source: hosted + version: "2.35.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "6a79c298171e6165823c41db058e10ad0e0d1f10af361a7e7c8d74f058435f50" + url: "https://pub.dev" + source: hosted + version: "10.3.3" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "4bc033841169d07f690d46d89dbc3f5305b6562820822445384c82e0866e2719" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 + url: "https://pub.dev" + source: hosted + version: "4.2.5" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + speech_to_text: + dependency: "direct main" + description: + name: speech_to_text + sha256: "75587f7400f485fdf166beacd471549d98fe5d58e634f708916bb65dec05d6a4" + url: "https://pub.dev" + source: hosted + version: "7.4.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a7e16e02853853ed7534ac2bde9a1c4f39c8879970a7974ac6ff832d4bdaa4b0 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2d1d10565b23262386b453b33656299608dc7a66784453735d6c1318f13f44d7" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.1 <4.0.0" + flutter: ">=3.38.4" diff --git a/kouyu_english/pubspec.yaml b/kouyu_english/pubspec.yaml new file mode 100644 index 0000000..e18771a --- /dev/null +++ b/kouyu_english/pubspec.yaml @@ -0,0 +1,99 @@ +name: kouyu_english +description: "开口英语 M1:面向 A0 成人的本地优先英语学习原型。" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_secure_storage: ^10.0.0 + flutter_tts: ^4.2.3 + audioplayers: ^6.5.1 + drift: ^2.30.0 + http: ^1.6.0 + path_provider: ^2.1.5 + record: ^6.1.0 + shared_preferences: ^2.5.4 + sqlite3_flutter_libs: ^0.5.39 + speech_to_text: ^7.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/kouyu_english/test/app_state_test.dart b/kouyu_english/test/app_state_test.dart new file mode 100644 index 0000000..57b0947 --- /dev/null +++ b/kouyu_english/test/app_state_test.dart @@ -0,0 +1,1103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/app_state.dart'; +import 'package:kouyu_english/core/a0_core.dart'; +import 'package:kouyu_english/core/assessment_bank.dart'; +import 'package:kouyu_english/core/generated_content.dart'; +import 'package:kouyu_english/core/models.dart'; +import 'package:kouyu_english/core/seed_courses.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + ReviewItem dueItem([String id = 'A0-P12']) => ReviewItem( + id: id, + target: "I'm from [place].", + prompt: 'Hi! Where are you from?', + hint: "I'm from Hong Kong.", + dueAt: DateTime.now(), + skill: '口语表达', + ); + + test('an overdue review is always the primary task', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + + expect(state.reviewIsPrimary, isTrue); + expect(state.dueReviewCount, 1); + }); + + test('review backlog follows time budget or a seven-day overdue item', () { + final overdue = AppState(); + overdue.reviewQueue.add( + dueItem().copyWith( + dueAt: DateTime.now().subtract(const Duration(days: 8)), + ), + ); + expect(overdue.reviewBacklog, isTrue); + + final crowded = AppState(); + for (var index = 0; index < 11; index++) { + crowded.reviewQueue.add(dueItem('A0-T$index')); + } + expect(crowded.dueReviewEstimatedSeconds, 11 * 60); + expect(crowded.reviewBacklog, isTrue); + }); + + test( + 'A0 reinforcement schedules a stable target with a new prompt variant', + () { + final state = AppState(); + state.scheduleA0Reinforcement(); + + expect(state.dueReviewCount, 1); + final item = state.dueReviews.single; + expect(a0CoreItems, contains(item.id)); + expect(item.variantIndex, 1); + expect(item.prompt, contains('情境')); + }, + ); + + test('an approved AI variant remains tied to its original review item', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + state.applyGeneratedReviewVariant( + const GeneratedReviewVariant( + variantId: 'ai-1', + targetItemId: 'A0-P12', + prompt: '换一个地点,用英语说你来自哪里。', + expectedAnswer: "I'm from [place].", + ), + ); + + expect(state.reviewQueue.single.id, 'A0-P12'); + expect(state.reviewQueue.single.isAiGenerated, isTrue); + }); + + test( + 'reporting an AI review variant isolates it and invalidates its success', + () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + state.applyGeneratedReviewVariant( + const GeneratedReviewVariant( + variantId: 'ai-report-1', + targetItemId: 'A0-P12', + prompt: '不合适的题面。', + expectedAnswer: "I'm from [place].", + ), + ); + final generated = state.reviewQueue.single; + state.completeReview( + generated, + assisted: false, + rawAnswer: "I'm from Hong Kong.", + ); + + state.reportGeneratedReviewVariant(state.reviewQueue.single); + + expect(state.reviewQueue.single.isAiGenerated, isFalse); + expect(state.reportedAiVariantKeys, contains('A0-P12:1')); + expect(state.mastery['A0-P12']!.checkpoint, 0); + expect( + state.attemptEvidence.any((entry) => entry.variantIndex == 1), + isFalse, + ); + }, + ); + + test('a completed review is scheduled again instead of being discarded', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + final item = state.dueReviews.first; + + state.completeReview(item, assisted: false); + + expect(state.reviewQueue, hasLength(1)); + final updated = state.reviewQueue.firstWhere( + (candidate) => candidate.id == item.id, + ); + expect(updated.successfulReviews, 1); + expect(updated.dueAt.isAfter(DateTime.now()), isTrue); + }); + + test('four independent review checkpoints lead to mastered status', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + final item = state.reviewQueue.first; + + for (var index = 0; index < 4; index++) { + final current = state.reviewQueue.firstWhere( + (candidate) => candidate.id == item.id, + ); + final queueIndex = state.reviewQueue.indexOf(current); + state.reviewQueue[queueIndex] = current.copyWith( + dueAt: DateTime.now(), + lastProgressedAt: DateTime.now().subtract(const Duration(days: 1)), + ); + state.completeReview( + state.reviewQueue.firstWhere((candidate) => candidate.id == item.id), + assisted: false, + ); + } + + expect(state.mastery[item.id]!.checkpoint, 4); + expect(state.mastery[item.id]!.status, MasteryStatus.master); + expect( + state.reviewQueue + .firstWhere((candidate) => candidate.id == item.id) + .dueAt + .isAfter(DateTime.now().add(const Duration(days: 29))), + isTrue, + ); + }); + + test('mastery can be rebuilt from spaced review evidence', () { + final state = AppState(); + final base = DateTime.utc(2026, 9, 1); + for (var index = 0; index < 4; index++) { + state.attemptEvidence.add( + AttemptEvidence( + id: 'rebuilt-$index', + itemId: 'A0-P12', + taskId: 'review-A0-P12', + skill: '口语表达', + inputMode: 'text', + outcome: EvidenceKind.independentSuccess, + createdAt: base.add(Duration(days: index + 1)), + ), + ); + } + + state.rebuildMasteryFromEvidence(); + + expect(state.mastery['A0-P12']!.checkpoint, 4); + expect(state.mastery['A0-P12']!.status, MasteryStatus.master); + }); + + test( + 'rebuild keeps first failure as review and second failure lowers checkpoint', + () { + final state = AppState(); + final base = DateTime.utc(2026, 9, 1); + for (var index = 0; index < 3; index++) { + state.attemptEvidence.add( + AttemptEvidence( + id: 'success-$index', + itemId: 'A0-P12', + taskId: 'review-A0-P12', + skill: '口语表达', + inputMode: 'text', + outcome: EvidenceKind.independentSuccess, + createdAt: base.add(Duration(days: index + 1)), + ), + ); + } + for (var index = 0; index < 2; index++) { + state.attemptEvidence.add( + AttemptEvidence( + id: 'failure-$index', + itemId: 'A0-P12', + taskId: 'review-A0-P12', + skill: '口语表达', + inputMode: 'text', + outcome: EvidenceKind.languageError, + createdAt: base.add(Duration(days: index + 10)), + ), + ); + } + + state.rebuildMasteryFromEvidence(); + + expect(state.mastery['A0-P12']!.checkpoint, 2); + expect(state.mastery['A0-P12']!.needsReview, isTrue); + }, + ); + + test('a same-day second success does not advance a review checkpoint', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + state.completeReview(state.reviewQueue.first, assisted: false); + state.completeReview(state.reviewQueue.first, assisted: false); + + final updated = state.reviewQueue.single; + expect(updated.successfulReviews, 1); + expect(state.mastery[updated.id]!.checkpoint, 1); + }); + + test( + 'a duplicate review submission creates no extra evidence or attempt', + () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + final submitted = state.reviewQueue.single; + + state.completeReview(submitted, assisted: false); + state.completeReview(submitted, assisted: false); + + expect(state.reviewQueue.single.attempts, 1); + expect(state.attemptEvidence, hasLength(1)); + expect(state.mastery[submitted.id]!.checkpoint, 1); + }, + ); + + test('one review failure requests a check, two lower one checkpoint', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + final item = state.reviewQueue.first; + state.mastery[item.id] = MasteryItem( + id: item.id, + label: item.target, + status: MasteryStatus.master, + checkpoint: 4, + evidence: const [EvidenceKind.independentSuccess], + ); + + state.reportReviewFailure(item); + expect(state.mastery[item.id]!.needsReview, isTrue); + expect(state.mastery[item.id]!.checkpoint, 4); + state.reportReviewFailure(item); + expect(state.mastery[item.id]!.checkpoint, 3); + expect(state.mastery[item.id]!.status, MasteryStatus.use); + }); + + test( + 'lesson dialogue must finish before independent attempt can complete lesson', + () { + final state = AppState(); + state.completePreview(); + state.completeListening(); + state.completeSpeaking(assisted: true); + state.completeReading(); + state.completeWriting(assisted: true); + + expect(state.lessonCanComplete, isFalse); + state.completeLessonDialogue(); + expect(state.lessonStep, LessonStep.independent); + state.completeIndependentAttempt(assisted: false); + expect(state.lessonCanComplete, isTrue); + }, + ); + + test( + 'restores lesson settings and review schedule from local storage', + () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.setDailyMinutes(30); + first.setGoal(LearningGoal.travel); + first.completePreview(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.dailyMinutes, 30); + expect(restored.goal, LearningGoal.travel); + expect(restored.lessonStep, LessonStep.listening); + expect(restored.reviewQueue, isEmpty); + }, + ); + + test('restores in-progress writing and independent-answer drafts', () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.advanceLesson(LessonStep.writing); + first.setLessonWritingDraft("I'm from Hong Kong."); + first.setIndependentAttemptDraft('I like tea.'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.lessonStep, LessonStep.writing); + expect(restored.lessonWritingDraft, "I'm from Hong Kong."); + expect(restored.independentAttemptDraft, 'I like tea.'); + }); + + test('restores an approved cached adaptive lesson', () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.cacheApprovedAdaptiveLesson( + const GeneratedLesson( + lessonId: 'ai-a0-p12-1', + revision: 1, + stageVersion: 'A0-1.0', + abilityIds: ['A0-C06'], + prerequisiteIds: ['a0-05'], + targetItemIds: ['A0-P12'], + receptiveChunks: [], + previewItemIds: [], + estimatedMinutes: 10, + tasks: [ + GeneratedLessonTask( + taskId: 'l', + skill: 'listening', + type: 'listenChoice', + prompt: '听。', + stimulus: 'I am from Hong Kong.', + answer: 'Hong Kong', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 's', + skill: 'speaking', + type: 'repeat', + prompt: '说。', + stimulus: 'I am from Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 'r', + skill: 'reading', + type: 'readAnswer', + prompt: '读。', + stimulus: 'I am from Hong Kong.', + answer: 'Hong Kong', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 'w', + skill: 'writing', + type: 'writeAnswer', + prompt: '写。', + stimulus: '来自香港。', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ), + ], + ), + auditedAt: DateTime.utc(2026, 9, 13, 9, 30), + auditor: 'compatible:test-model', + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.cachedAdaptiveLesson, isNotNull); + expect(restored.cachedAdaptiveLesson!.targetItemIds, ['A0-P12']); + expect( + restored.cachedAdaptiveLessonAuditedAt, + DateTime.utc(2026, 9, 13, 9, 30), + ); + expect(restored.cachedAdaptiveLessonAuditor, 'compatible:test-model'); + }); + + test( + 'adaptive lesson draft restores and a report isolates its cache', + () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.cacheApprovedAdaptiveLesson( + const GeneratedLesson( + lessonId: 'ai-a0-p12-2', + revision: 1, + stageVersion: 'A0-1.0', + abilityIds: ['A0-C06'], + prerequisiteIds: ['a0-05'], + targetItemIds: ['A0-P12'], + receptiveChunks: [], + previewItemIds: [], + estimatedMinutes: 10, + tasks: [ + GeneratedLessonTask( + taskId: 'l', + skill: 'listening', + type: 'listenChoice', + prompt: 'Listen.', + stimulus: 'I am from Hong Kong.', + answer: 'Hong Kong', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 's', + skill: 'speaking', + type: 'repeat', + prompt: 'Say it.', + stimulus: 'I am from Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 'r', + skill: 'reading', + type: 'readAnswer', + prompt: 'Read.', + stimulus: 'I am from Hong Kong.', + answer: 'Hong Kong', + targetItemIds: ['A0-P12'], + ), + GeneratedLessonTask( + taskId: 'w', + skill: 'writing', + type: 'writeAnswer', + prompt: 'Write.', + stimulus: 'From Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ), + ], + ), + ); + final lesson = first.cachedAdaptiveLesson!; + first.saveAdaptiveLessonDraft( + lesson: lesson, + taskIndex: 2, + answer: 'Hong Kong', + referenceShown: true, + usedVoice: true, + transcriptConfirmed: true, + originalTranscript: 'Hong Kong', + recordingPath: '/private/local/answer.m4a', + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.adaptiveLessonDraftId, 'ai-a0-p12-2'); + expect(restored.adaptiveLessonDraftIndex, 2); + expect(restored.adaptiveLessonDraftAnswer, 'Hong Kong'); + expect(restored.adaptiveLessonDraftReferenceShown, isTrue); + expect(restored.adaptiveLessonDraftUsedVoice, isTrue); + expect(restored.adaptiveLessonDraftTranscriptConfirmed, isTrue); + expect(restored.adaptiveLessonDraftTranscriptEdited, isFalse); + expect(restored.adaptiveLessonDraftOriginalTranscript, 'Hong Kong'); + expect( + restored.adaptiveLessonDraftRecordingPath, + '/private/local/answer.m4a', + ); + + restored.reportAdaptiveLesson(restored.cachedAdaptiveLesson!); + expect(restored.cachedAdaptiveLesson, isNull); + expect(restored.reportedAdaptiveLessonIds, contains('ai-a0-p12-2')); + }, + ); + + test('an edited adaptive transcript is persisted only as text evidence', () { + final state = AppState(); + const lesson = GeneratedLesson( + lessonId: 'ai-a0-p12-boundary', + revision: 1, + stageVersion: 'A0-1.0', + abilityIds: ['A0-C06'], + prerequisiteIds: ['a0-05'], + targetItemIds: ['A0-P12'], + receptiveChunks: [], + previewItemIds: [], + estimatedMinutes: 10, + tasks: [ + GeneratedLessonTask( + taskId: 'speaking', + skill: 'speaking', + type: 'repeat', + prompt: 'Say it.', + stimulus: 'I am from Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ), + ], + ); + const task = GeneratedLessonTask( + taskId: 'speaking', + skill: 'speaking', + type: 'repeat', + prompt: 'Say it.', + stimulus: 'I am from Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + ); + + // This deliberately contradictory input models a future caller bug. + // The domain layer, rather than only the widget, must protect evidence. + state.recordAdaptiveLessonTask( + lesson: lesson, + task: task, + rawAnswer: 'I am from Hong Kong.', + assisted: false, + correct: true, + inputMode: 'speechToText', + originalTranscript: 'I am from Home Kong.', + transcriptConfirmed: true, + transcriptEdited: true, + ); + + final evidence = state.attemptEvidence.single; + expect(evidence.inputMode, 'text'); + expect(evidence.transcriptConfirmed, isFalse); + expect(evidence.transcriptEdited, isTrue); + expect(evidence.originalTranscript, 'I am from Home Kong.'); + + state.saveAdaptiveLessonDraft( + lesson: lesson, + taskIndex: 0, + answer: evidence.rawAnswer ?? '', + referenceShown: false, + usedVoice: true, + transcriptConfirmed: true, + transcriptEdited: true, + originalTranscript: evidence.originalTranscript ?? '', + ); + expect(state.adaptiveLessonDraftTranscriptConfirmed, isFalse); + expect(state.adaptiveLessonDraftTranscriptEdited, isTrue); + + state.recordAdaptiveLessonTask( + lesson: lesson, + task: task, + rawAnswer: 'I am from Hong Kong.', + assisted: false, + correct: true, + ); + expect(state.attemptEvidence, hasLength(1)); + expect(state.mastery['A0-P12']!.status, MasteryStatus.recognize); + }); + + test( + 'temporary definitions remain local and survive state restoration', + () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.setAiProvider(AiProviderType.compatible); + first.saveAiConfiguration( + endpoint: 'https://example.test/v1', + model: 'demo', + ); + first.cacheTemporaryDefinition(query: 'library', definition: '图书馆'); + expect(first.mastery, isEmpty); + expect(first.reviewQueue, isEmpty); + expect(first.attemptEvidence, isEmpty); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + final entry = restored.temporaryDefinitionFor(' Library '); + expect(entry, isNotNull); + expect(entry!.definition, '图书馆'); + expect(entry.provider, 'compatible'); + expect(entry.model, 'demo'); + expect(restored.mastery, isEmpty); + expect(restored.reviewQueue, isEmpty); + expect(restored.attemptEvidence, isEmpty); + restored.removeTemporaryDefinition('library'); + expect(restored.temporaryDefinitionFor('library'), isNull); + expect(restored.mastery, isEmpty); + expect(restored.reviewQueue, isEmpty); + expect(restored.attemptEvidence, isEmpty); + }, + ); + + test('preview exposure alone does not add mastery or a review item', () { + final state = AppState(); + final reviewCount = state.reviewQueue.length; + final masteryCount = state.mastery.length; + + state.completePreview(); + + expect(state.reviewQueue, hasLength(reviewCount)); + expect(state.mastery, hasLength(masteryCount)); + expect(state.lessonStep, LessonStep.listening); + }); + + test( + 'each target records first formal teaching before lesson completion', + () { + final state = AppState(); + state.completePreview(); + state.completeListening(); + + for (final id in lessonById('a0-01').targetItemIds) { + expect(state.mastery[id]!.firstTaughtAt, isNotNull); + } + expect(state.lessonCanComplete, isFalse); + }, + ); + + test('lesson evidence is task- and target-specific', () { + final state = AppState(); + final targets = lessonById('a0-01').targetItemIds; + const segmentId = 'a0-01-a'; + state.completePreview(); + state.completeListening(); + + for (final id in targets) { + expect( + state.attemptEvidence.any( + (entry) => + entry.itemId == id && + entry.taskId == 'lesson-$segmentId-listening' && + entry.outcome == EvidenceKind.exposure, + ), + isTrue, + ); + } + state.completeSpeaking(); + final speaking = state.attemptEvidence.where( + (entry) => entry.taskId == 'lesson-$segmentId-speaking', + ); + expect(speaking, hasLength(1)); + expect(speaking.single.itemId, targets.last); + expect(speaking.single.outcome, EvidenceKind.independentSuccess); + }); + + test( + 'mastery rebuild derives first teaching and teaching level from evidence', + () { + final state = AppState(); + state.completePreview(); + state.completeListening(); + state.completeSpeaking(); + state.completeReading(); + state.completeWriting(rawAnswer: 'Hello. I am Mia.'); + state.completeLessonDialogue(); + state.completeIndependentAttempt( + assisted: false, + rawAnswer: 'Hello. I am Mia.', + ); + final primary = lessonById('a0-01').targetItemIds.last; + + state.mastery.clear(); + state.rebuildMasteryFromEvidence(); + + expect(state.mastery[primary]!.firstTaughtAt, isNotNull); + expect(state.mastery[primary]!.status, MasteryStatus.use); + expect( + state.mastery[lessonById('a0-01').targetItemIds.first]!.firstTaughtAt, + isNotNull, + ); + }, + ); + + test('every A0 seed lesson has complete local activities', () { + expect(a0SeedLessons, hasLength(10)); + for (final lesson in a0SeedLessons) { + final activity = activityByLessonId(lesson.id); + expect(lesson.vocabulary, isNotEmpty); + expect(activity.answers, hasLength(3)); + expect(activity.answers.first, isNotEmpty); + expect(activity.independentPrompt, isNotEmpty); + expect(lesson.schemaVersion, '1.0'); + expect(lesson.status, ContentStatus.approved); + expect(lesson.source, ContentSource.builtInOriginal); + expect(lesson.targetItemIds, isNotEmpty); + } + expect( + a0SeedLessons.expand((lesson) => lesson.targetItemIds).toSet(), + containsAll(a0CoreItems.keys), + ); + }); + + test('every A0 seed lesson has a practical grammar reminder', () { + for (final lesson in a0SeedLessons) { + expect(grammarNoteForLesson(lesson.id).trim(), isNotEmpty); + } + }); + + test('A0 lesson segments stay within new-item budget and cover targets', () { + for (final lesson in a0SeedLessons) { + final segmented = lesson.segments.expand( + (segment) => segment.targetItemIds, + ); + expect( + lesson.segments.every((segment) => segment.targetItemIds.length <= 8), + isTrue, + ); + expect(segmented.toSet(), equals(lesson.targetItemIds.toSet())); + } + }); + + test('segment completion advances only within its lesson', () { + final state = AppState(); + state.completeSegment('a0-04', 0); + + expect(state.isSegmentComplete('a0-04-a'), isTrue); + expect(state.isSegmentComplete('a0-04-b'), isFalse); + expect(state.activeSegmentIndexFor('a0-04'), 1); + }); + + test('split lesson segments have their own reviewed activities', () { + for (final lessonId in ['a0-04', 'a0-08']) { + for (final segment in lessonById(lessonId).segments) { + expect(a0SegmentActivities, contains(segment.id)); + expect(activityBySegmentId(segment.id, lessonId).listening, isNotEmpty); + } + } + expect(activityBySegmentId('a0-01-a', 'a0-01').writingPrompt, isNotEmpty); + }); + + test('split lesson segments have their own controlled dialogues', () { + for (final lessonId in ['a0-04', 'a0-08']) { + for (final segment in lessonById(lessonId).segments) { + expect(a0SegmentDialogues, contains(segment.id)); + expect(dialogueBySegmentId(segment.id, lessonId).prompts, isNotEmpty); + } + } + }); + + test('split lesson segments have reviewed vocabulary and grammar notes', () { + for (final lessonId in ['a0-04', 'a0-08']) { + for (final segment in lessonById(lessonId).segments) { + final vocabulary = vocabularyBySegmentId(segment.id, lessonId); + expect(vocabulary.map((item) => item.id), segment.previewItemIds); + expect(vocabulary.every((item) => item.meaning.isNotEmpty), isTrue); + expect(vocabulary.every((item) => item.example.isNotEmpty), isTrue); + expect(grammarNoteForSegment(segment.id, lessonId).trim(), isNotEmpty); + } + } + }); + + test('split dialogue validators require the current segment content', () { + expect(matchesSegmentDialogue('a0-04-a', 0, 'zero, one, two'), isTrue); + expect(matchesSegmentDialogue('a0-04-a', 0, 'hello'), isFalse); + expect( + matchesSegmentDialogue('a0-04-c', 0, 'My number is one-three-eight'), + isTrue, + ); + expect(matchesSegmentDialogue('a0-04-c', 0, 'one-three-eight'), isFalse); + expect(matchesSegmentDialogue('a0-08-c', 1, "It is three o'clock"), isTrue); + expect(matchesSegmentDialogue('a0-08-c', 1, 'It is Monday'), isFalse); + }); + + test('split independent attempts need their taught information', () { + expect(matchesSegmentIndependent('a0-04-a', 'zero, one, two'), isTrue); + expect(matchesSegmentIndependent('a0-04-a', 'zero, one'), isFalse); + expect(matchesSegmentIndependent('a0-08-b', 'It is Friday.'), isTrue); + expect(matchesSegmentIndependent('a0-08-b', 'It is Monday.'), isFalse); + }); + + test('completed independent dialogue creates one daily non-core recap', () { + final state = AppState(); + + state.addDialogueRecap('My name is Alex.'); + state.addDialogueRecap('My name is Mia.'); + + final recap = state.reviewQueue.where( + (item) => item.id.startsWith('dialogue-a0-meet-'), + ); + expect(recap, hasLength(1)); + expect(recap.single.target, 'My name is Alex.'); + expect(a0CoreItems, isNot(contains(recap.single.id))); + }); + + test('A0 upgrade denominator is the frozen 60-item core list', () { + expect(a0CoreItems, hasLength(60)); + expect(a0CoreItems['A0-W01'], 'zero'); + expect(a0CoreItems['A0-P20'], 'Please say that again.'); + }); + + test('the ten A0 lessons introduce every frozen core item to review', () { + final state = AppState(); + for (final lesson in a0SeedLessons) { + state.openLesson(lesson.id); + for (var segment = 0; segment < lesson.segments.length; segment++) { + state.completePreview(); + state.completeListening(); + state.completeSpeaking(assisted: true); + state.completeReading(); + state.completeWriting(assisted: true); + state.completeLessonDialogue(); + state.completeIndependentAttempt(assisted: true); + state.finishCurrentLessonSegment(); + } + } + + expect(state.mastery.keys.where(a0CoreItems.containsKey), hasLength(60)); + expect( + state.reviewQueue.map((item) => item.id).toSet(), + containsAll(a0CoreItems.keys), + ); + final fromReview = state.reviewQueue.firstWhere( + (item) => item.id == 'A0-P12', + ); + expect(fromReview.prompt, contains('来自哪里')); + expect(fromReview.prompt, isNot(contains("I'm from"))); + }); + + test('A0 pass requires two different, spaced four-skill assessments', () { + final state = AppState(); + for (final entry in a0CoreItems.entries) { + state.mastery[entry.key] = MasteryItem( + id: entry.key, + label: entry.value, + status: MasteryStatus.master, + evidence: const [EvidenceKind.independentSuccess], + ); + } + const results = { + AssessmentSkill.listening: true, + AssessmentSkill.speaking: true, + AssessmentSkill.reading: true, + AssessmentSkill.writing: true, + }; + final now = DateTime.now(); + state.recordAssessment( + AssessmentRecord( + packId: 'A', + completedAt: now.subtract(const Duration(hours: 25)), + results: results, + ), + ); + expect(state.a0Passed, isFalse); + state.recordAssessment( + AssessmentRecord(packId: 'B', completedAt: now, results: results), + ); + expect(state.a0Passed, isTrue); + }); + + test('both A0 assessment packs meet the documented task blueprint', () { + expect(a0AssessmentPacks, hasLength(2)); + for (final pack in a0AssessmentPacks) { + expect(pack.forSkill(AssessmentSkill.listening), hasLength(10)); + expect(pack.forSkill(AssessmentSkill.reading), hasLength(5)); + expect(pack.forSkill(AssessmentSkill.writing), hasLength(5)); + expect(pack.forSkill(AssessmentSkill.speaking), hasLength(8)); + expect( + pack.tasks.map((task) => task.id).toSet(), + hasLength(pack.tasks.length), + ); + } + }); + + test('open A0 assessment tasks require their actual information slots', () { + final pack = a0AssessmentPacks.first; + final writing = pack.forSkill(AssessmentSkill.writing); + final speaking = pack.forSkill(AssessmentSkill.speaking); + expect(checkOpenAssessmentAnswer(writing[1], 'Hong Kong.'), isFalse); + expect( + checkOpenAssessmentAnswer(writing[1], "I'm from Hong Kong."), + isTrue, + ); + expect(checkOpenAssessmentAnswer(speaking[2], 'one.'), isFalse); + expect(checkOpenAssessmentAnswer(speaking[2], 'one-three-eight.'), isTrue); + expect( + checkOpenAssessmentAnswer( + speaking.first, + 'Hello. I am Alex. I am from Beijing. I like tea. What do you like?', + ), + isTrue, + ); + expect( + checkOpenAssessmentAnswer(speaking[5], "It's three o'clock."), + isTrue, + ); + expect( + checkOpenAssessmentAnswer(speaking[5], 'It is two o’clock.'), + isTrue, + ); + expect( + checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'), + isTrue, + ); + expect( + checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), + isTrue, + ); + }); + + test('finishLesson advances activeLessonId to next available lesson', () { + final state = AppState(); + expect(state.activeLessonId, 'a0-01'); + state.completePreview(); + state.completeListening(); + state.completeSpeaking(assisted: false); + state.completeReading(); + state.completeWriting(assisted: false); + state.completeLessonDialogue(); + state.completeIndependentAttempt(assisted: false); + expect(state.lessonCanComplete, isTrue); + state.finishLesson(); + + expect(state.completedLessonIds, contains('a0-01')); + expect(state.activeLessonId, 'a0-02'); + }); + + test( + 'a passed assessment skill remains valid during the seven-day retest window', + () { + final state = AppState(); + final now = DateTime.now(); + state.recordAssessment( + AssessmentRecord( + packId: 'A0-E1', + completedAt: now.subtract(const Duration(days: 1)), + results: const { + AssessmentSkill.listening: true, + AssessmentSkill.speaking: false, + AssessmentSkill.reading: false, + AssessmentSkill.writing: false, + }, + ), + ); + final merged = state.recordAssessment( + AssessmentRecord( + packId: 'A0-E1', + completedAt: now, + results: const { + AssessmentSkill.listening: false, + AssessmentSkill.speaking: true, + AssessmentSkill.reading: false, + AssessmentSkill.writing: false, + }, + ), + ); + expect(merged.results[AssessmentSkill.listening], isTrue); + expect(merged.results[AssessmentSkill.speaking], isTrue); + }, + ); + + test('a technical speaking gap remains pending instead of failed', () { + final state = AppState(); + final record = state.recordAssessment( + AssessmentRecord( + packId: 'A0-E1', + completedAt: DateTime.now(), + results: const { + AssessmentSkill.listening: true, + AssessmentSkill.reading: true, + AssessmentSkill.writing: true, + AssessmentSkill.speaking: false, + }, + pendingSkills: const {AssessmentSkill.speaking}, + ), + ); + expect(record.pendingSkills, contains(AssessmentSkill.speaking)); + expect(record.failedSkills, isNot(contains(AssessmentSkill.speaking))); + }); + + test('replacement assessment packs use a different task value set', () { + expect(a0ReplacementPacks, hasLength(2)); + expect(replacementFor('A0-E1')!.id, 'A0-E1R'); + expect( + a0ReplacementPacks.first.tasks.first.audio, + isNot(a0AssessmentPacks.first.tasks.first.audio), + ); + }); + + test( + 'replacement results merge into their original seven-day skill window', + () { + final state = AppState(); + final now = DateTime.now(); + state.recordAssessment( + AssessmentRecord( + packId: 'A0-E1', + completedAt: now.subtract(const Duration(days: 1)), + results: const {AssessmentSkill.listening: true}, + ), + ); + final merged = state.recordAssessment( + AssessmentRecord( + packId: 'A0-E1R', + completedAt: now, + results: const {AssessmentSkill.speaking: true}, + ), + ); + expect(merged.packId, 'A0-E1'); + expect(merged.results[AssessmentSkill.listening], isTrue); + expect(merged.results[AssessmentSkill.speaking], isTrue); + }, + ); + + test('assessment draft survives a local-state restore', () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.saveAssessmentDraft( + const AssessmentDraft( + packId: 'A0-E1', + taskIndex: 4, + results: {'A0-A-L1': true}, + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + final restored = AppState(); + await restored.load(); + expect(restored.assessmentDraft!.packId, 'A0-E1'); + expect(restored.assessmentDraft!.taskIndex, 4); + expect(restored.assessmentDraft!.results['A0-A-L1'], isTrue); + }); + + test('review attempt evidence survives a local-state restore', () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + final item = dueItem(); + first.reviewQueue.add(item); + first.completeReview( + item, + assisted: true, + rawAnswer: "I'm from Hong Kong.", + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.attemptEvidence, hasLength(1)); + expect(restored.attemptEvidence.single.itemId, 'A0-P12'); + expect(restored.attemptEvidence.single.rawAnswer, "I'm from Hong Kong."); + expect(restored.attemptEvidence.single.assisted, isTrue); + expect(restored.attemptEvidence.single.outcome, EvidenceKind.assisted); + }); + + test('clearing progress also removes local learning evidence', () { + final state = AppState(); + final item = dueItem(); + state.reviewQueue.add(item); + state.completeReview(item, assisted: false, rawAnswer: "I'm from London."); + + state.clearProgress(); + + expect(state.attemptEvidence, isEmpty); + expect(state.reviewQueue, isEmpty); + expect(state.mastery, isEmpty); + }); + + test('clearing progress keeps AI service configuration', () { + final state = AppState(); + state.setAiProvider(AiProviderType.compatible); + state.saveAiConfiguration( + endpoint: 'https://example.test/v1', + model: 'test-model', + ); + + state.clearProgress(); + + expect(state.aiProvider, AiProviderType.compatible); + expect(state.aiEndpoint, 'https://example.test/v1'); + expect(state.aiModel, 'test-model'); + }); + + test('lesson dialogue draft survives a local-state restore', () async { + SharedPreferences.setMockInitialValues({}); + final first = AppState(); + await first.load(); + first.saveDialogueDraft( + const DialogueDraft( + lessonId: 'a0-03', + stage: 2, + usedHelp: true, + turns: [ + DialogueTurn(text: 'Hi! How are you?', isLearner: false), + DialogueTurn(text: 'I am good.', isLearner: true), + ], + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final restored = AppState(); + await restored.load(); + expect(restored.dialogueDraft!.lessonId, 'a0-03'); + expect(restored.dialogueDraft!.stage, 2); + expect(restored.dialogueDraft!.usedHelp, isTrue); + expect(restored.dialogueDraft!.turns, hasLength(2)); + expect(restored.dialogueDraft!.turns.last.isLearner, isTrue); + }); +} diff --git a/kouyu_english/test/generated_content_test.dart b/kouyu_english/test/generated_content_test.dart new file mode 100644 index 0000000..ba1c117 --- /dev/null +++ b/kouyu_english/test/generated_content_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/generated_content.dart'; + +void main() { + const valid = '''{ + "schemaVersion":"review-variant-1", + "variantId":"ai-a0-p12-001", + "targetItemId":"A0-P12", + "prompt":"请用英语说你来自哪里。", + "expectedAnswer":"I’m from [place]." + }'''; + + test('accepts a bounded variant for its requested frozen target', () { + final item = decodeGeneratedReviewVariant( + valid, + expectedTargetItemId: 'A0-P12', + ); + + expect(item, isNotNull); + expect(item!.targetItemId, 'A0-P12'); + }); + + test('rejects an AI variant that changes the target identity', () { + expect( + decodeGeneratedReviewVariant(valid, expectedTargetItemId: 'A0-P01'), + isNull, + ); + }); + + test('rejects unknown schema fields', () { + final invalid = valid.replaceFirst('}', ',"unsafe":"ignore"}'); + expect( + decodeGeneratedReviewVariant(invalid, expectedTargetItemId: 'A0-P12'), + isNull, + ); + }); + + const writing = '''{ + "schemaVersion":"writing-feedback-1", + "lessonId":"a0-06", + "verdict":"rewrite", + "feedback":"加上 from 就能说清来自哪里。", + "suggestion":"I'm from Hong Kong.", + "missing":["来自哪里"] + }'''; + + test('accepts bounded writing feedback for its lesson', () { + final feedback = decodeWritingAiFeedback( + writing, + expectedLessonId: 'a0-06', + ); + + expect(feedback, isNotNull); + expect(feedback!.verdict, 'rewrite'); + }); + + test('rejects writing feedback for a different lesson', () { + expect(decodeWritingAiFeedback(writing, expectedLessonId: 'a0-07'), isNull); + }); + + const lesson = '''{ + "schemaVersion":"lesson-1", + "lessonId":"ai-a0-p12-1", + "revision":1, + "stageVersion":"A0-1.0", + "source":"aiGenerated", + "status":"validated", + "abilityIds":["A0-C06"], + "prerequisiteIds":["a0-05"], + "targetItemIds":["A0-P12"], + "receptiveChunks":[], + "newItemIds":[], + "previewItemIds":[], + "estimatedMinutes":10, + "tasks":[ + {"taskId":"l","skill":"listening","type":"listenChoice","prompt":"听后选择。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]}, + {"taskId":"s","skill":"speaking","type":"repeat","prompt":"跟读。","stimulus":"I am from Hong Kong.","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]}, + {"taskId":"r","skill":"reading","type":"readAnswer","prompt":"读后回答。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]}, + {"taskId":"w","skill":"writing","type":"writeAnswer","prompt":"写一句。","stimulus":"来自香港。","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]} + ] + }'''; + + test('accepts a bounded four-skill adaptive lesson', () { + final decoded = decodeGeneratedLesson( + lesson, + expectedTargetItemId: 'A0-P12', + ); + + expect(decoded, isNotNull); + expect(decoded!.tasks.map((task) => task.skill).toSet(), hasLength(4)); + }); + + test('adaptive answer check accepts harmless punctuation variation', () { + final task = decodeGeneratedLesson( + lesson, + expectedTargetItemId: 'A0-P12', + )!.tasks[3]; + + expect(matchesAdaptiveLessonAnswer(task, "i am from hong kong!"), isTrue); + }); + + test('adaptive answer check rejects a missing target token', () { + final task = decodeGeneratedLesson( + lesson, + expectedTargetItemId: 'A0-P12', + )!.tasks[3]; + + expect(matchesAdaptiveLessonAnswer(task, 'I am from Hong.'), isFalse); + }); + + test('adaptive answer spec accepts listed slots and rejects exclusions', () { + const task = GeneratedLessonTask( + taskId: 'speaking', + skill: 'speaking', + type: 'repeat', + prompt: '说一句。', + stimulus: 'I am from Hong Kong.', + answer: 'I am from Hong Kong.', + targetItemIds: ['A0-P12'], + answerSpec: GeneratedAnswerSpec( + requiredAnyPhrases: [ + ['I am', "I'm"], + ['from'], + ['Hong Kong'], + ], + acceptedAnswers: ['I am from Hong Kong.', "I'm from Hong Kong."], + forbiddenPhrases: ['Beijing'], + ), + ); + + expect(matchesAdaptiveLessonAnswer(task, "I'm from Hong Kong!"), isTrue); + expect(matchesAdaptiveLessonAnswer(task, 'I am from Beijing.'), isFalse); + expect(matchesAdaptiveLessonAnswer(task, 'Hong Kong.'), isFalse); + }); + + test('rejects an adaptive lesson with a different frozen target', () { + expect( + decodeGeneratedLesson(lesson, expectedTargetItemId: 'A0-P11'), + isNull, + ); + }); + + test( + 'rejects an adaptive lesson that introduces an unknown English word', + () { + final invalid = lesson.replaceFirst('Hong Kong', 'library'); + + expect( + decodeGeneratedLesson(invalid, expectedTargetItemId: 'A0-P12'), + isNull, + ); + }, + ); +} diff --git a/kouyu_english/test/lexicon_lookup_test.dart b/kouyu_english/test/lexicon_lookup_test.dart new file mode 100644 index 0000000..c1b58b2 --- /dev/null +++ b/kouyu_english/test/lexicon_lookup_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:kouyu_english/core/app_state.dart'; +import 'package:kouyu_english/widgets/lexicon_lookup.dart'; + +void main() { + test('lookup prefers the longest reviewed phrase', () { + final entry = findCourseLexicon('Nice to meet you, Mia.'); + + expect(entry, isNotNull); + expect(entry!.word, 'Nice to meet you'); + expect(entry.meaning, isNotEmpty); + }); + + test('lookup includes reviewed vocabulary from split segments', () { + final entry = findCourseLexicon("It's three o'clock."); + + expect(entry, isNotNull); + expect(entry!.word, "It's three o'clock."); + }); + + test('unknown text has no invented dictionary entry', () { + expect(findCourseLexicon('supercalifragilistic'), isNull); + }); + + test('lookup does not match substrings within longer words', () { + expect(findCourseLexicon('often'), isNull); + expect(findCourseLexicon('sentence'), isNull); + expect(findCourseLexicon('teacher'), isNull); + }); + + test('lookup matches reviewed vocabulary with punctuation and apostrophes', () { + expect(findCourseLexicon('tea'), isNotNull); + expect(findCourseLexicon('tea.'), isNotNull); + expect(findCourseLexicon("What's this?"), isNotNull); + expect(findCourseLexicon('What’s this?'), isNotNull); + }); + + testWidgets('cached temporary definition appears when lookup opens', ( + tester, + ) async { + final state = AppState() + ..cacheTemporaryDefinition(query: 'library', definition: '图书馆'); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showLexiconLookup( + context, + state: state, + initialText: 'library', + ), + child: const Text('查词'), + ), + ), + ), + ); + await tester.tap(find.text('查词')); + await tester.pumpAndSettle(); + + expect(find.textContaining('待审核临时释义'), findsOneWidget); + expect(find.textContaining('图书馆'), findsOneWidget); + }); + + testWidgets('selected arbitrary text exposes a dynamic lookup action', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: LexiconText('mystery widget', state: AppState())), + ), + ); + + expect(find.byType(SelectableText), findsOneWidget); + await tester.longPress(find.text('mystery widget')); + await tester.pumpAndSettle(); + + expect(find.text('查询已选文本'), findsOneWidget); + }); +} diff --git a/kouyu_english/test/local_store_test.dart b/kouyu_english/test/local_store_test.dart new file mode 100644 index 0000000..7f897a1 --- /dev/null +++ b/kouyu_english/test/local_store_test.dart @@ -0,0 +1,165 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/local_store.dart'; +import 'dart:convert'; + +void main() { + test( + 'a legacy snapshot migrates into canonical entities without replacement', + () async { + final executor = NativeDatabase.memory(); + final store = LocalSnapshotStore.forTesting(executor); + const legacy = '''{ + "onboardingComplete": true, + "lessonStep": "reading", + "activeLessonId": "a0-02", + "attemptEvidence": [], + "mastery": [], + "reviews": [] + }'''; + + // Open the schema first, then model the row left by a pre-entity build. + expect(await store.read(), isNull); + await executor.runCustom( + '''INSERT INTO learning_snapshots(snapshot_id, schema_version, payload, updated_at) + VALUES (?, ?, ?, ?)''', + const ['current', 1, legacy, '2026-09-14T00:00:00.000Z'], + ); + expect(await store.read(), legacy); + + await store.write(legacy); + final restored = + jsonDecode((await store.read())!) as Map; + expect(restored['activeLessonId'], 'a0-02'); + expect(restored['onboardingComplete'], isTrue); + expect( + await executor.runSelect( + 'SELECT payload FROM learning_snapshots WHERE snapshot_id = ?', + const ['current'], + ), + [ + {'payload': legacy}, + ], + ); + expect( + await executor.runSelect( + 'SELECT profile_id FROM learner_profiles', + const [], + ), + [ + {'profile_id': 'current'}, + ], + ); + await executor.close(); + }, + ); + + test( + 'SQLite entity store restores canonical state without writing a snapshot', + () async { + final executor = NativeDatabase.memory(); + final store = LocalSnapshotStore.forTesting(executor); + + expect(await store.read(), isNull); + const first = '''{ + "revision": 1, + "attemptEvidence": [{"id":"a1","itemId":"A0-P01","taskId":"t1","skill":"speaking","inputMode":"speechToText","outcome":"independentSuccess","createdAt":"2026-09-14T00:00:00.000Z","assisted":false,"variantIndex":0,"originalTranscript":"Hello","transcriptConfirmed":true,"transcriptEdited":false}], + "mastery": [{"id":"A0-P01","label":"Hello","status":"recognize","checkpoint":1,"needsReview":false}], + "reviews": [{"id":"A0-P01","dueAt":"2026-09-15T00:00:00.000Z","attempts":1,"successfulReviews":1,"variantIndex":0}], + "temporaryLexicon": [{"query":"library","definition":"图书馆","provider":"compatible","model":"demo","createdAt":"2026-09-14T00:00:00.000Z"}], + "lessonStep":"speaking", "activeLessonId":"a0-01" + }'''; + await store.write(first); + final restored = + jsonDecode((await store.read())!) as Map; + expect(restored['lessonStep'], 'speaking'); + expect(restored['attemptEvidence'], hasLength(1)); + expect(restored['mastery'], hasLength(1)); + expect( + await executor.runSelect( + 'SELECT snapshot_id FROM learning_snapshots', + const [], + ), + isEmpty, + ); + expect( + await executor.runSelect( + 'SELECT item_id FROM attempt_evidence', + const [], + ), + [ + {'item_id': 'A0-P01'}, + ], + ); + + await executor.runCustom( + 'UPDATE learning_snapshots SET payload = ? WHERE snapshot_id = ?', + const ['{"corrupt":"backup only"}', 'current'], + ); + final canonicalAfterSnapshotChange = + jsonDecode((await store.read())!) as Map; + expect(canonicalAfterSnapshotChange['mastery'], hasLength(1)); + expect( + await executor.runSelect( + 'SELECT original_transcript, transcript_confirmed, transcript_edited FROM attempt_evidence', + const [], + ), + [ + { + 'original_transcript': 'Hello', + 'transcript_confirmed': 1, + 'transcript_edited': 0, + }, + ], + ); + expect( + await executor.runSelect( + 'SELECT definition FROM temporary_lexicon_entries', + const [], + ), + [ + {'definition': '图书馆'}, + ], + ); + expect( + await executor.runSelect('SELECT status FROM mastery_items', const []), + [ + {'status': 'recognize'}, + ], + ); + expect( + await executor.runSelect('SELECT item_id FROM review_items', const []), + [ + {'item_id': 'A0-P01'}, + ], + ); + expect( + await executor.runSelect( + 'SELECT session_type FROM study_sessions', + const [], + ), + [ + {'session_type': 'lesson'}, + ], + ); + + await store.write('{"revision":2}'); + expect( + (jsonDecode((await store.read())!) + as Map)['attemptEvidence'], + isEmpty, + ); + + await store.clear(); + expect(await store.read(), isNull); + expect( + await executor.runSelect( + 'SELECT query_key FROM temporary_lexicon_entries', + const [], + ), + isEmpty, + ); + await executor.close(); + }, + ); +} diff --git a/kouyu_english/test/review_feedback_test.dart b/kouyu_english/test/review_feedback_test.dart new file mode 100644 index 0000000..2b3191e --- /dev/null +++ b/kouyu_english/test/review_feedback_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/models.dart'; +import 'package:kouyu_english/core/review_feedback.dart'; + +ReviewItem review(String id, String target) => ReviewItem( + id: id, + target: target, + prompt: 'Test prompt', + hint: target, + dueAt: DateTime(2026), + skill: '回忆表达', +); + +void main() { + test('does not accept unrelated English for a core phrase', () { + final result = ReviewFeedback.check( + review('A0-P12', "I'm from [place]."), + 'I like tea.', + ); + expect(result.complete, isFalse); + }); + + test('accepts a new place in the from pattern', () { + final result = ReviewFeedback.check( + review('A0-P12', "I'm from [place]."), + "I'm from Guangzhou.", + ); + expect(result.complete, isTrue); + }); + + test('checks an A0 word without accepting a different word', () { + expect( + ReviewFeedback.check(review('A0-W08', 'three'), 'four').complete, + isFalse, + ); + expect( + ReviewFeedback.check(review('A0-W08', 'three'), 'three').complete, + isTrue, + ); + }); + + test('A0-P17 accepts standard o clock expressions', () { + expect( + ReviewFeedback.check( + review('A0-P17', "It's [hour] o'clock."), + "It's three o'clock.", + ).complete, + isTrue, + ); + expect( + ReviewFeedback.check( + review('A0-P17', "It's [hour] o'clock."), + 'It is two o’clock.', + ).complete, + isTrue, + ); + expect( + ReviewFeedback.check( + review('A0-P17', "It's [hour] o'clock."), + 'It is five oclock.', + ).complete, + isTrue, + ); + }); + + test('A0-P20 accepts please say that again and please speak slowly', () { + expect( + ReviewFeedback.check( + review('A0-P20', 'Please say that again.'), + 'Please say that again.', + ).complete, + isTrue, + ); + expect( + ReviewFeedback.check( + review('A0-P20', 'Please say that again.'), + 'Please speak slowly.', + ).complete, + isTrue, + ); + }); +} diff --git a/kouyu_english/test/widget_test.dart b/kouyu_english/test/widget_test.dart new file mode 100644 index 0000000..38a452e --- /dev/null +++ b/kouyu_english/test/widget_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:kouyu_english/core/app_state.dart'; +import 'package:kouyu_english/core/models.dart'; +import 'package:kouyu_english/features/lesson/lesson_flow.dart'; +import 'package:kouyu_english/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('shows the M1 welcome flow', (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(const KouyuEnglishApp()); + await tester.pumpAndSettle(); + + expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget); + expect(find.text('继续'), findsOneWidget); + + await tester.tap(find.text('继续')); + await tester.pumpAndSettle(); + + expect(find.text('从哪里开始?'), findsOneWidget); + expect(find.text('直接从第一课开始'), findsOneWidget); + }); + + testWidgets('writing input updates the lesson draft and enables checking', ( + tester, + ) async { + final state = AppState()..lessonStep = LessonStep.writing; + await tester.pumpWidget( + MaterialApp( + home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}), + ), + ); + + await tester.enterText(find.byType(TextField), 'Hello. I am Shen.'); + await tester.pump(); + + expect(state.lessonWritingDraft, 'Hello. I am Shen.'); + final check = tester.widget( + find.widgetWithText(FilledButton, '检查句子'), + ); + expect(check.onPressed, isNotNull); + }); +} diff --git a/kouyu_english/test/writing_feedback_test.dart b/kouyu_english/test/writing_feedback_test.dart new file mode 100644 index 0000000..5b10b31 --- /dev/null +++ b/kouyu_english/test/writing_feedback_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/writing_feedback.dart'; + +void main() { + test('accepts a variable name in the first lesson', () { + final result = WritingFeedback.check('a0-01', 'Hello. I am Alex.'); + expect(result.complete, isTrue); + }); + + test( + 'requests the missing sentence frame without rejecting a valid place', + () { + expect(WritingFeedback.check('a0-06', 'Hong Kong.').complete, isFalse); + expect( + WritingFeedback.check('a0-06', "I'm from Shanghai.").complete, + isTrue, + ); + }, + ); + + test('requires the three independent parts in lesson ten', () { + expect( + WritingFeedback.check('a0-10', "I'm Alex. I'm from Beijing.").complete, + isFalse, + ); + expect( + WritingFeedback.check( + 'a0-10', + "I'm Alex. I'm from Beijing. I like tea.", + ).complete, + isTrue, + ); + }); +}